@xincli/opentui-core 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. package/README.md +63 -0
  2. package/dev/keypress-debug-renderer.ts +148 -0
  3. package/dev/keypress-debug.ts +43 -0
  4. package/dev/print-env-vars.ts +32 -0
  5. package/dev/test-tmux-graphics-334.sh +68 -0
  6. package/dev/test-tmux-theme-queries.sh +151 -0
  7. package/dev/thai-debug-test.ts +68 -0
  8. package/dev/theme-mode.ts +290 -0
  9. package/docs/development.md +144 -0
  10. package/package.json +102 -0
  11. package/prebuilt/aarch64-android/libopentui.so +0 -0
  12. package/scripts/build-android.ts +226 -0
  13. package/scripts/build-native-termux.sh +525 -0
  14. package/scripts/build.ts +530 -0
  15. package/scripts/dist-test.ts +312 -0
  16. package/scripts/package-prebuilt.ts +110 -0
  17. package/scripts/publish.ts +60 -0
  18. package/scripts/test-node-hook.mjs +16 -0
  19. package/scripts/test-node.ts +241 -0
  20. package/scripts/vendor-deps.sh +73 -0
  21. package/src/NativeSpanFeed.ts +375 -0
  22. package/src/Renderable.ts +1878 -0
  23. package/src/__snapshots__/buffer.test.ts.snap +28 -0
  24. package/src/animation/Timeline.test.ts +2709 -0
  25. package/src/animation/Timeline.ts +598 -0
  26. package/src/ansi.ts +22 -0
  27. package/src/audio.ts +479 -0
  28. package/src/benchmark/attenuation-benchmark.ts +81 -0
  29. package/src/benchmark/audio-playback-benchmark.ts +344 -0
  30. package/src/benchmark/box-draw-benchmark.ts +1042 -0
  31. package/src/benchmark/colormatrix-benchmark.ts +123 -0
  32. package/src/benchmark/gain-benchmark.ts +80 -0
  33. package/src/benchmark/latest-all-bench-run.json +707 -0
  34. package/src/benchmark/latest-async-bench-run.json +336 -0
  35. package/src/benchmark/latest-default-bench-run.json +657 -0
  36. package/src/benchmark/latest-large-bench-run.json +707 -0
  37. package/src/benchmark/latest-quick-bench-run.json +207 -0
  38. package/src/benchmark/layout-benchmark.ts +2547 -0
  39. package/src/benchmark/markdown-benchmark.ts +1796 -0
  40. package/src/benchmark/native-span-feed-async-benchmark.ts +355 -0
  41. package/src/benchmark/native-span-feed-benchmark.md +56 -0
  42. package/src/benchmark/native-span-feed-benchmark.ts +596 -0
  43. package/src/benchmark/native-span-feed-compare.ts +280 -0
  44. package/src/benchmark/render-traversal-benchmark.ts +928 -0
  45. package/src/benchmark/text-buffer-render-benchmark.ts +874 -0
  46. package/src/benchmark/text-table-benchmark.ts +948 -0
  47. package/src/buffer.test.ts +292 -0
  48. package/src/buffer.ts +564 -0
  49. package/src/console.test.ts +629 -0
  50. package/src/console.ts +1253 -0
  51. package/src/edit-buffer.test.ts +1852 -0
  52. package/src/edit-buffer.ts +417 -0
  53. package/src/editor-view.test.ts +1056 -0
  54. package/src/editor-view.ts +290 -0
  55. package/src/index.ts +25 -0
  56. package/src/lib/KeyHandler.integration.test.ts +292 -0
  57. package/src/lib/KeyHandler.stopPropagation.test.ts +289 -0
  58. package/src/lib/KeyHandler.test.ts +662 -0
  59. package/src/lib/KeyHandler.ts +222 -0
  60. package/src/lib/RGBA.test.ts +60 -0
  61. package/src/lib/RGBA.ts +343 -0
  62. package/src/lib/ascii.font.ts +330 -0
  63. package/src/lib/border.test.ts +83 -0
  64. package/src/lib/border.ts +170 -0
  65. package/src/lib/bunfs.test.ts +27 -0
  66. package/src/lib/bunfs.ts +18 -0
  67. package/src/lib/clipboard.test.ts +63 -0
  68. package/src/lib/clipboard.ts +41 -0
  69. package/src/lib/clock.ts +35 -0
  70. package/src/lib/data-paths.test.ts +133 -0
  71. package/src/lib/data-paths.ts +109 -0
  72. package/src/lib/debounce.ts +106 -0
  73. package/src/lib/detect-links.test.ts +98 -0
  74. package/src/lib/detect-links.ts +56 -0
  75. package/src/lib/env.test.ts +228 -0
  76. package/src/lib/env.ts +209 -0
  77. package/src/lib/extmarks-history.ts +51 -0
  78. package/src/lib/extmarks-multiwidth.test.ts +324 -0
  79. package/src/lib/extmarks.test.ts +3547 -0
  80. package/src/lib/extmarks.ts +861 -0
  81. package/src/lib/fonts/block.json +405 -0
  82. package/src/lib/fonts/grid.json +265 -0
  83. package/src/lib/fonts/huge.json +741 -0
  84. package/src/lib/fonts/pallet.json +314 -0
  85. package/src/lib/fonts/shade.json +591 -0
  86. package/src/lib/fonts/slick.json +321 -0
  87. package/src/lib/fonts/tiny.json +69 -0
  88. package/src/lib/hast-styled-text.ts +59 -0
  89. package/src/lib/index.ts +22 -0
  90. package/src/lib/keybinding.internal.test.ts +342 -0
  91. package/src/lib/keybinding.internal.ts +182 -0
  92. package/src/lib/objects-in-viewport.test.ts +789 -0
  93. package/src/lib/objects-in-viewport.ts +153 -0
  94. package/src/lib/output.capture.ts +58 -0
  95. package/src/lib/parse.keypress-kitty.protocol.test.ts +347 -0
  96. package/src/lib/parse.keypress-kitty.test.ts +712 -0
  97. package/src/lib/parse.keypress-kitty.ts +470 -0
  98. package/src/lib/parse.keypress.test.ts +2020 -0
  99. package/src/lib/parse.keypress.ts +480 -0
  100. package/src/lib/parse.mouse.test.ts +552 -0
  101. package/src/lib/parse.mouse.ts +232 -0
  102. package/src/lib/paste.ts +18 -0
  103. package/src/lib/queue.ts +65 -0
  104. package/src/lib/render-geometry.ts +36 -0
  105. package/src/lib/renderable.validations.test.ts +87 -0
  106. package/src/lib/renderable.validations.ts +83 -0
  107. package/src/lib/scroll-acceleration.ts +98 -0
  108. package/src/lib/selection.ts +261 -0
  109. package/src/lib/singleton.ts +34 -0
  110. package/src/lib/stdin-parser.test.ts +2439 -0
  111. package/src/lib/stdin-parser.ts +2007 -0
  112. package/src/lib/styled-text.ts +178 -0
  113. package/src/lib/terminal-capability-detection.test.ts +226 -0
  114. package/src/lib/terminal-capability-detection.ts +98 -0
  115. package/src/lib/terminal-palette.test.ts +954 -0
  116. package/src/lib/terminal-palette.ts +441 -0
  117. package/src/lib/tree-sitter/assets/README.md +119 -0
  118. package/src/lib/tree-sitter/assets/javascript/highlights.scm +205 -0
  119. package/src/lib/tree-sitter/assets/javascript/tree-sitter-javascript.wasm +0 -0
  120. package/src/lib/tree-sitter/assets/markdown/highlights.scm +150 -0
  121. package/src/lib/tree-sitter/assets/markdown/injections.scm +27 -0
  122. package/src/lib/tree-sitter/assets/markdown/tree-sitter-markdown.wasm +0 -0
  123. package/src/lib/tree-sitter/assets/markdown_inline/highlights.scm +115 -0
  124. package/src/lib/tree-sitter/assets/markdown_inline/tree-sitter-markdown_inline.wasm +0 -0
  125. package/src/lib/tree-sitter/assets/typescript/highlights.scm +604 -0
  126. package/src/lib/tree-sitter/assets/typescript/tree-sitter-typescript.wasm +0 -0
  127. package/src/lib/tree-sitter/assets/update.ts +360 -0
  128. package/src/lib/tree-sitter/assets/zig/highlights.scm +284 -0
  129. package/src/lib/tree-sitter/assets/zig/tree-sitter-zig.wasm +0 -0
  130. package/src/lib/tree-sitter/assets.d.ts +9 -0
  131. package/src/lib/tree-sitter/cache.test.ts +291 -0
  132. package/src/lib/tree-sitter/client.test.ts +1434 -0
  133. package/src/lib/tree-sitter/client.ts +847 -0
  134. package/src/lib/tree-sitter/default-parsers.ts +133 -0
  135. package/src/lib/tree-sitter/download-utils.ts +148 -0
  136. package/src/lib/tree-sitter/index.ts +75 -0
  137. package/src/lib/tree-sitter/parser.worker.ts +1098 -0
  138. package/src/lib/tree-sitter/parsers-config.ts +81 -0
  139. package/src/lib/tree-sitter/resolve-ft.test.ts +55 -0
  140. package/src/lib/tree-sitter/resolve-ft.ts +189 -0
  141. package/src/lib/tree-sitter/types.ts +132 -0
  142. package/src/lib/tree-sitter/update-assets.ts +10 -0
  143. package/src/lib/tree-sitter-styled-text.test.ts +1253 -0
  144. package/src/lib/tree-sitter-styled-text.ts +314 -0
  145. package/src/lib/validate-dir-name.ts +55 -0
  146. package/src/lib/yoga.options.test.ts +628 -0
  147. package/src/lib/yoga.options.ts +346 -0
  148. package/src/native-event-worker-repro.test.ts +48 -0
  149. package/src/native-event-worker-repro.worker.ts +9 -0
  150. package/src/native-handle.test.ts +221 -0
  151. package/src/platform/ffi.test.ts +516 -0
  152. package/src/platform/ffi.ts +653 -0
  153. package/src/platform/runtime.test.ts +52 -0
  154. package/src/platform/runtime.ts +121 -0
  155. package/src/platform/test.ts +45 -0
  156. package/src/platform/worker-handler-cleanup.fixture.ts +12 -0
  157. package/src/platform/worker-onmessage-startup.fixture.ts +10 -0
  158. package/src/platform/worker-startup.fixture.ts +9 -0
  159. package/src/platform/worker.node-test.ts +153 -0
  160. package/src/platform/worker.ts +425 -0
  161. package/src/plugins/core-slot.ts +579 -0
  162. package/src/plugins/registry.ts +402 -0
  163. package/src/plugins/types.ts +46 -0
  164. package/src/post/effects.ts +948 -0
  165. package/src/post/filters.ts +512 -0
  166. package/src/post/matrices.ts +288 -0
  167. package/src/renderables/ASCIIFont.ts +219 -0
  168. package/src/renderables/Box.test.ts +474 -0
  169. package/src/renderables/Box.ts +353 -0
  170. package/src/renderables/Code.test.ts +2388 -0
  171. package/src/renderables/Code.ts +555 -0
  172. package/src/renderables/Diff.regression.test.ts +381 -0
  173. package/src/renderables/Diff.test.ts +3212 -0
  174. package/src/renderables/Diff.ts +1278 -0
  175. package/src/renderables/EditBufferRenderable.test.ts +288 -0
  176. package/src/renderables/EditBufferRenderable.ts +1152 -0
  177. package/src/renderables/FrameBuffer.ts +47 -0
  178. package/src/renderables/Input.test.ts +1310 -0
  179. package/src/renderables/Input.ts +273 -0
  180. package/src/renderables/LineNumberRenderable.ts +725 -0
  181. package/src/renderables/Markdown.ts +2144 -0
  182. package/src/renderables/ScrollBar.ts +423 -0
  183. package/src/renderables/ScrollBox.ts +903 -0
  184. package/src/renderables/Select.test.ts +1134 -0
  185. package/src/renderables/Select.ts +535 -0
  186. package/src/renderables/Slider.test.ts +456 -0
  187. package/src/renderables/Slider.ts +342 -0
  188. package/src/renderables/TabSelect.test.ts +238 -0
  189. package/src/renderables/TabSelect.ts +447 -0
  190. package/src/renderables/Text.selection-buffer.test.ts +118 -0
  191. package/src/renderables/Text.test.ts +2662 -0
  192. package/src/renderables/Text.ts +147 -0
  193. package/src/renderables/TextBufferRenderable.ts +504 -0
  194. package/src/renderables/TextNode.test.ts +1137 -0
  195. package/src/renderables/TextNode.ts +379 -0
  196. package/src/renderables/TextTable.test.ts +1411 -0
  197. package/src/renderables/TextTable.ts +1434 -0
  198. package/src/renderables/Textarea.ts +425 -0
  199. package/src/renderables/TimeToFirstDraw.ts +89 -0
  200. package/src/renderables/__snapshots__/Code.test.ts.snap +13 -0
  201. package/src/renderables/__snapshots__/Diff.test.ts.snap +785 -0
  202. package/src/renderables/__snapshots__/Text.test.ts.snap +421 -0
  203. package/src/renderables/__snapshots__/TextTable.test.ts.snap +215 -0
  204. package/src/renderables/__tests__/Code.test.ts +55 -0
  205. package/src/renderables/__tests__/LineNumberRenderable.scrollbox-simple.test.ts +144 -0
  206. package/src/renderables/__tests__/LineNumberRenderable.scrollbox.test.ts +827 -0
  207. package/src/renderables/__tests__/LineNumberRenderable.test.ts +1906 -0
  208. package/src/renderables/__tests__/LineNumberRenderable.wrapping.test.ts +85 -0
  209. package/src/renderables/__tests__/Markdown.code-colors.test.ts +288 -0
  210. package/src/renderables/__tests__/Markdown.test.ts +4175 -0
  211. package/src/renderables/__tests__/MultiRenderable.selection.test.ts +87 -0
  212. package/src/renderables/__tests__/Textarea.buffer.test.ts +683 -0
  213. package/src/renderables/__tests__/Textarea.destroyed-events.test.ts +675 -0
  214. package/src/renderables/__tests__/Textarea.editing.test.ts +2120 -0
  215. package/src/renderables/__tests__/Textarea.error-handling.test.ts +35 -0
  216. package/src/renderables/__tests__/Textarea.events.test.ts +738 -0
  217. package/src/renderables/__tests__/Textarea.highlights.test.ts +590 -0
  218. package/src/renderables/__tests__/Textarea.keybinding.test.ts +3275 -0
  219. package/src/renderables/__tests__/Textarea.paste.test.ts +357 -0
  220. package/src/renderables/__tests__/Textarea.rendering.test.ts +1927 -0
  221. package/src/renderables/__tests__/Textarea.scroll.test.ts +733 -0
  222. package/src/renderables/__tests__/Textarea.selection.test.ts +1589 -0
  223. package/src/renderables/__tests__/Textarea.stress.test.ts +670 -0
  224. package/src/renderables/__tests__/Textarea.undo-redo.test.ts +383 -0
  225. package/src/renderables/__tests__/Textarea.visual-lines.test.ts +310 -0
  226. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.code.test.ts.snap +221 -0
  227. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox-simple.test.ts.snap +89 -0
  228. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.scrollbox.test.ts.snap +457 -0
  229. package/src/renderables/__tests__/__snapshots__/LineNumberRenderable.test.ts.snap +158 -0
  230. package/src/renderables/__tests__/__snapshots__/Textarea.rendering.test.ts.snap +387 -0
  231. package/src/renderables/__tests__/markdown-parser.test.ts +241 -0
  232. package/src/renderables/__tests__/renderable-test-utils.ts +60 -0
  233. package/src/renderables/composition/README.md +8 -0
  234. package/src/renderables/composition/VRenderable.ts +32 -0
  235. package/src/renderables/composition/constructs.ts +127 -0
  236. package/src/renderables/composition/vnode.ts +277 -0
  237. package/src/renderables/index.ts +23 -0
  238. package/src/renderables/markdown-parser.ts +79 -0
  239. package/src/renderer-theme-mode.ts +199 -0
  240. package/src/renderer.ts +4975 -0
  241. package/src/runtime-plugin-support-configure.ts +63 -0
  242. package/src/runtime-plugin-support.ts +13 -0
  243. package/src/runtime-plugin.ts +626 -0
  244. package/src/specs/terminal-startup.md +64 -0
  245. package/src/syntax-style.test.ts +841 -0
  246. package/src/syntax-style.ts +274 -0
  247. package/src/testing/README.md +219 -0
  248. package/src/testing/bun-test-node.ts +880 -0
  249. package/src/testing/capture-spans.test.ts +216 -0
  250. package/src/testing/integration.test.ts +276 -0
  251. package/src/testing/manual-clock.ts +117 -0
  252. package/src/testing/mock-keys.test.ts +1378 -0
  253. package/src/testing/mock-keys.ts +457 -0
  254. package/src/testing/mock-mouse.test.ts +218 -0
  255. package/src/testing/mock-mouse.ts +247 -0
  256. package/src/testing/mock-tree-sitter-client.ts +81 -0
  257. package/src/testing/spy.ts +13 -0
  258. package/src/testing/terminal-capabilities.ts +47 -0
  259. package/src/testing/test-recorder.test.ts +410 -0
  260. package/src/testing/test-recorder.ts +133 -0
  261. package/src/testing/test-renderer.ts +375 -0
  262. package/src/testing/test-renderer.wait.test.ts +278 -0
  263. package/src/testing/test-streams.ts +31 -0
  264. package/src/testing.ts +9 -0
  265. package/src/tests/__snapshots__/absolute-positioning.snapshot.test.ts.snap +481 -0
  266. package/src/tests/__snapshots__/renderable.snapshot.test.ts.snap +19 -0
  267. package/src/tests/__snapshots__/scrollbox.test.ts.snap +29 -0
  268. package/src/tests/absolute-positioning.snapshot.test.ts +638 -0
  269. package/src/tests/allocator-stats.test.ts +53 -0
  270. package/src/tests/audio.test.ts +303 -0
  271. package/src/tests/destroy-during-render.test.ts +200 -0
  272. package/src/tests/destroy-on-exit.fixture.ts +36 -0
  273. package/src/tests/destroy-on-exit.test.ts +62 -0
  274. package/src/tests/ffi-borrowed-pointer-callsites.test.ts +153 -0
  275. package/src/tests/hover-cursor.test.ts +98 -0
  276. package/src/tests/native-backed-measurement-lifecycle.test.ts +133 -0
  277. package/src/tests/native-backed-measurement-parity.test.ts +322 -0
  278. package/src/tests/native-span-feed-async.test.ts +173 -0
  279. package/src/tests/native-span-feed-close.test.ts +120 -0
  280. package/src/tests/native-span-feed-coverage.test.ts +227 -0
  281. package/src/tests/native-span-feed-edge-cases.test.ts +384 -0
  282. package/src/tests/native-span-feed-use-after-free.test.ts +45 -0
  283. package/src/tests/opacity.test.ts +123 -0
  284. package/src/tests/renderable.snapshot.test.ts +524 -0
  285. package/src/tests/renderable.test.ts +1851 -0
  286. package/src/tests/renderer.clock.test.ts +270 -0
  287. package/src/tests/renderer.console-startup.test.ts +2151 -0
  288. package/src/tests/renderer.control.test.ts +458 -0
  289. package/src/tests/renderer.core-slot-binding.test.ts +962 -0
  290. package/src/tests/renderer.cursor.test.ts +26 -0
  291. package/src/tests/renderer.custom-stdout.test.ts +1112 -0
  292. package/src/tests/renderer.destroy-during-render.test.ts +147 -0
  293. package/src/tests/renderer.focus-restore.test.ts +257 -0
  294. package/src/tests/renderer.focus.test.ts +329 -0
  295. package/src/tests/renderer.idle.test.ts +219 -0
  296. package/src/tests/renderer.input.test.ts +2615 -0
  297. package/src/tests/renderer.kitty-flags.test.ts +195 -0
  298. package/src/tests/renderer.lifecycle.test.ts +34 -0
  299. package/src/tests/renderer.mouse.test.ts +1315 -0
  300. package/src/tests/renderer.notifications.test.ts +27 -0
  301. package/src/tests/renderer.palette.test.ts +1199 -0
  302. package/src/tests/renderer.remote.test.ts +247 -0
  303. package/src/tests/renderer.render-stats.test.ts +146 -0
  304. package/src/tests/renderer.scrollback-surface.test.ts +474 -0
  305. package/src/tests/renderer.selection.test.ts +138 -0
  306. package/src/tests/renderer.slot-registry.test.ts +684 -0
  307. package/src/tests/renderer.tracker.test.ts +202 -0
  308. package/src/tests/renderer.useMouse.test.ts +83 -0
  309. package/src/tests/runtime-plugin-node-modules-cycle.fixture.ts +76 -0
  310. package/src/tests/runtime-plugin-node-modules-import-like-string.fixture.ts +48 -0
  311. package/src/tests/runtime-plugin-node-modules-mjs.fixture.ts +43 -0
  312. package/src/tests/runtime-plugin-node-modules-no-bare-rewrite.fixture.ts +67 -0
  313. package/src/tests/runtime-plugin-node-modules-package-type-cache.fixture.ts +72 -0
  314. package/src/tests/runtime-plugin-node-modules-runtime-specifier.fixture.ts +44 -0
  315. package/src/tests/runtime-plugin-node-modules-scoped-package-bare-rewrite.fixture.ts +85 -0
  316. package/src/tests/runtime-plugin-path-alias.fixture.ts +43 -0
  317. package/src/tests/runtime-plugin-resolve-roots.fixture.ts +65 -0
  318. package/src/tests/runtime-plugin-support-configure.fixture.ts +31 -0
  319. package/src/tests/runtime-plugin-support-late-addition.fixture.ts +17 -0
  320. package/src/tests/runtime-plugin-support.fixture.ts +11 -0
  321. package/src/tests/runtime-plugin-support.test.ts +54 -0
  322. package/src/tests/runtime-plugin-windows-file-url.fixture.ts +30 -0
  323. package/src/tests/runtime-plugin.fixture.ts +40 -0
  324. package/src/tests/runtime-plugin.test.ts +369 -0
  325. package/src/tests/scrollbox-culling-bug.test.ts +114 -0
  326. package/src/tests/scrollbox-hitgrid-resize.test.ts +136 -0
  327. package/src/tests/scrollbox-hitgrid.test.ts +909 -0
  328. package/src/tests/scrollbox.test.ts +1895 -0
  329. package/src/tests/wrap-resize-perf.test.ts +276 -0
  330. package/src/tests/yoga-api-coverage.test.ts +409 -0
  331. package/src/tests/yoga-callback-stress.test.ts +122 -0
  332. package/src/tests/yoga-ffi.test.ts +117 -0
  333. package/src/tests/yoga-setters.test.ts +921 -0
  334. package/src/tests/yoga-upstream/YGAlignBaselineTest.test.ts +124 -0
  335. package/src/tests/yoga-upstream/YGComputedBorderTest.test.ts +27 -0
  336. package/src/tests/yoga-upstream/YGComputedMarginTest.test.ts +27 -0
  337. package/src/tests/yoga-upstream/YGComputedPaddingTest.test.ts +27 -0
  338. package/src/tests/yoga-upstream/YGDirtiedTest.test.ts +157 -0
  339. package/src/tests/yoga-upstream/YGErrataTest.test.ts +40 -0
  340. package/src/tests/yoga-upstream/YGFlexBasisAuto.test.ts +23 -0
  341. package/src/tests/yoga-upstream/YGHasNewLayout.test.ts +81 -0
  342. package/src/tests/yoga-upstream/YGMeasureCacheTest.test.ts +31 -0
  343. package/src/tests/yoga-upstream/YGMeasureTest.test.ts +70 -0
  344. package/src/tests/yoga-upstream/generated/YGAbsolutePositionTest.test.ts +1864 -0
  345. package/src/tests/yoga-upstream/generated/YGAlignContentTest.test.ts +5829 -0
  346. package/src/tests/yoga-upstream/generated/YGAlignItemsTest.test.ts +2444 -0
  347. package/src/tests/yoga-upstream/generated/YGAlignSelfTest.test.ts +293 -0
  348. package/src/tests/yoga-upstream/generated/YGAndroidNewsFeed.test.ts +315 -0
  349. package/src/tests/yoga-upstream/generated/YGAspectRatioTest.test.ts +231 -0
  350. package/src/tests/yoga-upstream/generated/YGAutoTest.test.ts +347 -0
  351. package/src/tests/yoga-upstream/generated/YGBorderTest.test.ts +256 -0
  352. package/src/tests/yoga-upstream/generated/YGBoxSizingTest.test.ts +2747 -0
  353. package/src/tests/yoga-upstream/generated/YGDimensionTest.test.ts +129 -0
  354. package/src/tests/yoga-upstream/generated/YGDisplayContentsTest.test.ts +109 -0
  355. package/src/tests/yoga-upstream/generated/YGDisplayTest.test.ts +1097 -0
  356. package/src/tests/yoga-upstream/generated/YGFlexDirectionTest.test.ts +4562 -0
  357. package/src/tests/yoga-upstream/generated/YGFlexTest.test.ts +677 -0
  358. package/src/tests/yoga-upstream/generated/YGFlexWrapTest.test.ts +2071 -0
  359. package/src/tests/yoga-upstream/generated/YGGapTest.test.ts +3303 -0
  360. package/src/tests/yoga-upstream/generated/YGIntrinsicSizeTest.test.ts +582 -0
  361. package/src/tests/yoga-upstream/generated/YGJustifyContentTest.test.ts +2150 -0
  362. package/src/tests/yoga-upstream/generated/YGMarginTest.test.ts +1963 -0
  363. package/src/tests/yoga-upstream/generated/YGMinMaxDimensionTest.test.ts +1446 -0
  364. package/src/tests/yoga-upstream/generated/YGPaddingTest.test.ts +354 -0
  365. package/src/tests/yoga-upstream/generated/YGPercentageTest.test.ts +1760 -0
  366. package/src/tests/yoga-upstream/generated/YGRoundingTest.test.ts +1161 -0
  367. package/src/tests/yoga-upstream/generated/YGSizeOverflowTest.test.ts +207 -0
  368. package/src/tests/yoga-upstream/generated/YGStaticPositionTest.test.ts +6338 -0
  369. package/src/tests/yoga-upstream/tools/MeasureCounter.ts +54 -0
  370. package/src/tests/yoga-upstream/tools/utils.ts +81 -0
  371. package/src/text-buffer-view.test.ts +722 -0
  372. package/src/text-buffer-view.ts +193 -0
  373. package/src/text-buffer.test.ts +512 -0
  374. package/src/text-buffer.ts +250 -0
  375. package/src/types.ts +199 -0
  376. package/src/utils.ts +88 -0
  377. package/src/yoga.ts +1146 -0
  378. package/src/zig/ansi.zig +502 -0
  379. package/src/zig/audio.zig +1206 -0
  380. package/src/zig/bench/README.md +50 -0
  381. package/src/zig/bench/buffer-color-blending_bench.zig +350 -0
  382. package/src/zig/bench/buffer-draw-box_bench.zig +786 -0
  383. package/src/zig/bench/buffer-draw-text-buffer_bench.zig +894 -0
  384. package/src/zig/bench/edit-buffer_bench.zig +476 -0
  385. package/src/zig/bench/editor-view_bench.zig +169 -0
  386. package/src/zig/bench/native-span-feed_bench.zig +107 -0
  387. package/src/zig/bench/rope-markers_bench.zig +713 -0
  388. package/src/zig/bench/rope_bench.zig +514 -0
  389. package/src/zig/bench/styled-text_bench.zig +476 -0
  390. package/src/zig/bench/text-buffer-coords_bench.zig +362 -0
  391. package/src/zig/bench/text-buffer-view_bench.zig +459 -0
  392. package/src/zig/bench/text-chunk-graphemes_bench.zig +272 -0
  393. package/src/zig/bench/utf8_bench.zig +799 -0
  394. package/src/zig/bench-utils.zig +431 -0
  395. package/src/zig/bench.zig +223 -0
  396. package/src/zig/buffer-methods.zig +215 -0
  397. package/src/zig/buffer.zig +2515 -0
  398. package/src/zig/build.zig +700 -0
  399. package/src/zig/build.zig.zon +19 -0
  400. package/src/zig/edit-buffer.zig +900 -0
  401. package/src/zig/editor-view.zig +812 -0
  402. package/src/zig/event-bus.zig +28 -0
  403. package/src/zig/event-emitter.zig +65 -0
  404. package/src/zig/file-logger.zig +92 -0
  405. package/src/zig/grapheme.zig +838 -0
  406. package/src/zig/handles.zig +267 -0
  407. package/src/zig/lib.zig +2813 -0
  408. package/src/zig/link.zig +349 -0
  409. package/src/zig/logger.zig +43 -0
  410. package/src/zig/mem-registry.zig +126 -0
  411. package/src/zig/miniaudio_shim.c +21 -0
  412. package/src/zig/native-renderable.zig +112 -0
  413. package/src/zig/native-span-feed-bench-lib.zig +7 -0
  414. package/src/zig/native-span-feed.zig +747 -0
  415. package/src/zig/renderer-output.zig +677 -0
  416. package/src/zig/renderer.zig +2106 -0
  417. package/src/zig/rope.zig +1220 -0
  418. package/src/zig/split-scrollback.zig +88 -0
  419. package/src/zig/syntax-style.zig +174 -0
  420. package/src/zig/terminal.zig +1652 -0
  421. package/src/zig/test.zig +82 -0
  422. package/src/zig/tests/README.md +18 -0
  423. package/src/zig/tests/ansi_test.zig +18 -0
  424. package/src/zig/tests/audio_test.zig +439 -0
  425. package/src/zig/tests/buffer-methods_test.zig +1111 -0
  426. package/src/zig/tests/buffer_test.zig +2926 -0
  427. package/src/zig/tests/edit-buffer-history_test.zig +299 -0
  428. package/src/zig/tests/edit-buffer_test.zig +1688 -0
  429. package/src/zig/tests/editor-view_test.zig +3454 -0
  430. package/src/zig/tests/event-emitter_test.zig +249 -0
  431. package/src/zig/tests/grapheme_test.zig +1329 -0
  432. package/src/zig/tests/handles_test.zig +123 -0
  433. package/src/zig/tests/link_test.zig +220 -0
  434. package/src/zig/tests/mem-registry_test.zig +473 -0
  435. package/src/zig/tests/memory_leak_regression_test.zig +234 -0
  436. package/src/zig/tests/native-renderable_test.zig +16 -0
  437. package/src/zig/tests/native-span-feed_test.zig +1266 -0
  438. package/src/zig/tests/renderer_test.zig +2604 -0
  439. package/src/zig/tests/rope-nested_test.zig +712 -0
  440. package/src/zig/tests/rope_fuzz_test.zig +238 -0
  441. package/src/zig/tests/rope_test.zig +2362 -0
  442. package/src/zig/tests/segment-merge.test.zig +148 -0
  443. package/src/zig/tests/split-scrollback_test.zig +70 -0
  444. package/src/zig/tests/syntax-style_test.zig +558 -0
  445. package/src/zig/tests/terminal_test.zig +1489 -0
  446. package/src/zig/tests/test-renderer.zig +126 -0
  447. package/src/zig/tests/text-buffer-drawing_test.zig +3291 -0
  448. package/src/zig/tests/text-buffer-highlights_test.zig +706 -0
  449. package/src/zig/tests/text-buffer-iterators_test.zig +776 -0
  450. package/src/zig/tests/text-buffer-segment_test.zig +320 -0
  451. package/src/zig/tests/text-buffer-selection_test.zig +1056 -0
  452. package/src/zig/tests/text-buffer-selection_viewport_test.zig +358 -0
  453. package/src/zig/tests/text-buffer-view_test.zig +3672 -0
  454. package/src/zig/tests/text-buffer_test.zig +2191 -0
  455. package/src/zig/tests/unicode-width-map.zon +3909 -0
  456. package/src/zig/tests/utf8_no_zwj_test.zig +260 -0
  457. package/src/zig/tests/utf8_test.zig +4060 -0
  458. package/src/zig/tests/utf8_wcwidth_cursor_test.zig +267 -0
  459. package/src/zig/tests/utf8_wcwidth_test.zig +357 -0
  460. package/src/zig/tests/word-wrap-editing_test.zig +498 -0
  461. package/src/zig/tests/wrap-cache-perf_test.zig +113 -0
  462. package/src/zig/tests/yoga_test.zig +65 -0
  463. package/src/zig/text-buffer-iterators.zig +496 -0
  464. package/src/zig/text-buffer-segment.zig +403 -0
  465. package/src/zig/text-buffer-view.zig +1428 -0
  466. package/src/zig/text-buffer.zig +1272 -0
  467. package/src/zig/utf8.zig +1954 -0
  468. package/src/zig/utils.zig +12 -0
  469. package/src/zig/uucode-stub.zig +76 -0
  470. package/src/zig/vendor/miniaudio/miniaudio.h +93468 -0
  471. package/src/zig/yoga.zig +658 -0
  472. package/src/zig-structs.ts +381 -0
  473. package/src/zig.ts +5260 -0
  474. package/tsconfig.build.json +17 -0
  475. package/tsconfig.json +27 -0
  476. package/tsconfig.node-test.json +138 -0
@@ -0,0 +1,1954 @@
1
+ const std = @import("std");
2
+ const uucode = @import("uucode");
3
+
4
+ /// The method to use when calculating the width of a grapheme
5
+ pub const WidthMethod = enum {
6
+ wcwidth,
7
+ unicode,
8
+ no_zwj,
9
+ };
10
+
11
+ /// Check if a byte slice contains only printable ASCII (32..126)
12
+ /// Uses SIMD16 for fast checking
13
+ pub fn isAsciiOnly(text: []const u8) bool {
14
+ if (text.len == 0) return false;
15
+
16
+ const vector_len = 16;
17
+ const Vec = @Vector(vector_len, u8);
18
+
19
+ const min_printable: Vec = @splat(32);
20
+ const max_printable: Vec = @splat(126);
21
+
22
+ var pos: usize = 0;
23
+
24
+ // Process full 16-byte vectors
25
+ while (pos + vector_len <= text.len) {
26
+ const chunk: Vec = text[pos..][0..vector_len].*;
27
+
28
+ // Check if all bytes are in [32, 126]
29
+ const too_low = chunk < min_printable;
30
+ const too_high = chunk > max_printable;
31
+
32
+ // Check if any byte is out of range
33
+ if (@reduce(.Or, too_low) or @reduce(.Or, too_high)) {
34
+ return false;
35
+ }
36
+
37
+ pos += vector_len;
38
+ }
39
+
40
+ // Handle remaining bytes with scalar code
41
+ while (pos < text.len) : (pos += 1) {
42
+ const b = text[pos];
43
+ if (b < 32 or b > 126) {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ return true;
49
+ }
50
+
51
+ pub const LineBreakKind = enum {
52
+ LF, // \n (Unix/Linux)
53
+ CR, // \r (Old Mac)
54
+ CRLF, // \r\n (Windows)
55
+ };
56
+
57
+ pub const LineBreak = struct {
58
+ pos: usize,
59
+ kind: LineBreakKind,
60
+ };
61
+
62
+ pub const LineBreakResult = struct {
63
+ breaks: std.ArrayListUnmanaged(LineBreak),
64
+ allocator: std.mem.Allocator,
65
+
66
+ pub fn init(allocator: std.mem.Allocator) LineBreakResult {
67
+ return .{
68
+ .breaks = .{},
69
+ .allocator = allocator,
70
+ };
71
+ }
72
+
73
+ pub fn deinit(self: *LineBreakResult) void {
74
+ self.breaks.deinit(self.allocator);
75
+ self.* = undefined;
76
+ }
77
+
78
+ pub fn reset(self: *LineBreakResult) void {
79
+ self.breaks.clearRetainingCapacity();
80
+ }
81
+ };
82
+
83
+ pub const TabStopResult = struct {
84
+ positions: std.ArrayListUnmanaged(usize),
85
+ allocator: std.mem.Allocator,
86
+
87
+ pub fn init(allocator: std.mem.Allocator) TabStopResult {
88
+ return .{
89
+ .positions = .{},
90
+ .allocator = allocator,
91
+ };
92
+ }
93
+
94
+ pub fn deinit(self: *TabStopResult) void {
95
+ self.positions.deinit(self.allocator);
96
+ self.* = undefined;
97
+ }
98
+
99
+ pub fn reset(self: *TabStopResult) void {
100
+ self.positions.clearRetainingCapacity();
101
+ }
102
+ };
103
+
104
+ pub const WrapBreak = struct {
105
+ // byte_offset points at the grapheme that creates this break opportunity.
106
+ // For whitespace and punctuation, this is the delimiter grapheme.
107
+ // For CJK<->ASCII transitions, this is the last grapheme in the previous run.
108
+ byte_offset: u32,
109
+
110
+ // char_offset is grapheme-count based, not a display column.
111
+ // Callers convert it to columns with charOffsetToColumn().
112
+ char_offset: u32,
113
+ };
114
+
115
+ pub const WrapBreakResult = struct {
116
+ breaks: std.ArrayListUnmanaged(WrapBreak),
117
+ allocator: std.mem.Allocator,
118
+
119
+ pub fn init(allocator: std.mem.Allocator) WrapBreakResult {
120
+ return .{
121
+ .breaks = .{},
122
+ .allocator = allocator,
123
+ };
124
+ }
125
+
126
+ pub fn deinit(self: *WrapBreakResult) void {
127
+ self.breaks.deinit(self.allocator);
128
+ self.* = undefined;
129
+ }
130
+
131
+ pub fn reset(self: *WrapBreakResult) void {
132
+ self.breaks.clearRetainingCapacity();
133
+ }
134
+ };
135
+
136
+ // Helper function to check if an ASCII byte is a wrap break point (CR/LF excluded)
137
+ inline fn isAsciiWrapBreak(b: u8) bool {
138
+ return switch (b) {
139
+ ' ', '\t' => true, // Whitespace (no CR/LF in inputs)
140
+ '-' => true, // Dash
141
+ '/', '\\' => true, // Slashes
142
+ '.', ',', ';', ':', '!', '?' => true, // Punctuation
143
+ '(', ')', '[', ']', '{', '}' => true, // Brackets
144
+ else => false,
145
+ };
146
+ }
147
+
148
+ // Decode a UTF-8 codepoint starting at pos. Assumes valid UTF-8 input.
149
+ // Returns (codepoint, length). If the remaining bytes are insufficient, returns length 1.
150
+ pub inline fn decodeUtf8Unchecked(text: []const u8, pos: usize) struct { cp: u21, len: u3 } {
151
+ const b0 = text[pos];
152
+ if (b0 < 0x80) return .{ .cp = @intCast(b0), .len = 1 };
153
+
154
+ if (pos + 1 >= text.len) return .{ .cp = 0xFFFD, .len = 1 };
155
+ const b1 = text[pos + 1];
156
+
157
+ if ((b0 & 0xE0) == 0xC0) {
158
+ const cp2: u21 = @intCast((@as(u32, b0 & 0x1F) << 6) | @as(u32, b1 & 0x3F));
159
+ return .{ .cp = cp2, .len = 2 };
160
+ }
161
+
162
+ if (pos + 2 >= text.len) return .{ .cp = 0xFFFD, .len = 1 };
163
+ const b2 = text[pos + 2];
164
+
165
+ if ((b0 & 0xF0) == 0xE0) {
166
+ const cp3: u21 = @intCast((@as(u32, b0 & 0x0F) << 12) | (@as(u32, b1 & 0x3F) << 6) | @as(u32, b2 & 0x3F));
167
+ return .{ .cp = cp3, .len = 3 };
168
+ }
169
+
170
+ if (pos + 3 >= text.len) return .{ .cp = 0xFFFD, .len = 1 };
171
+ const b3 = text[pos + 3];
172
+ const cp4: u21 = @intCast((@as(u32, b0 & 0x07) << 18) | (@as(u32, b1 & 0x3F) << 12) | (@as(u32, b2 & 0x3F) << 6) | @as(u32, b3 & 0x3F));
173
+ return .{ .cp = cp4, .len = 4 };
174
+ }
175
+
176
+ // Unicode wrap-break codepoints
177
+ inline fn isUnicodeWrapBreak(cp: u21) bool {
178
+ return switch (cp) {
179
+ 0x00A0, // NBSP
180
+ 0x1680, // OGHAM SPACE MARK
181
+ 0x2000...0x200A, // En quad..Hair space
182
+ 0x202F, // NARROW NO-BREAK SPACE
183
+ 0x205F, // MEDIUM MATHEMATICAL SPACE
184
+ 0x3000, // IDEOGRAPHIC SPACE
185
+ 0x200B, // ZERO WIDTH SPACE
186
+ 0x00AD, // SOFT HYPHEN
187
+ 0x2010, // HYPHEN
188
+ 0x3001, // IDEOGRAPHIC COMMA
189
+ 0x3002, // IDEOGRAPHIC FULL STOP
190
+ 0xFF01, // FULLWIDTH EXCLAMATION MARK
191
+ 0xFF0C, // FULLWIDTH COMMA
192
+ 0xFF1A, // FULLWIDTH COLON
193
+ 0xFF1F, // FULLWIDTH QUESTION MARK
194
+ => true,
195
+ else => false,
196
+ };
197
+ }
198
+
199
+ // WordClass keeps word-boundary behavior predictable in mixed-script text.
200
+ // We split between ASCII word runs and CJK word runs, and we keep each
201
+ // CJK run grouped as one unit.
202
+ const WordClass = enum {
203
+ ascii_word,
204
+ cjk_word,
205
+ other,
206
+ };
207
+
208
+ inline fn isAsciiWordByte(b: u8) bool {
209
+ return (b >= 'a' and b <= 'z') or
210
+ (b >= 'A' and b <= 'Z') or
211
+ (b >= '0' and b <= '9') or
212
+ b == '_';
213
+ }
214
+
215
+ inline fn isCjkWordCodepoint(cp: u21) bool {
216
+ return
217
+ // Han ideographs
218
+ (cp >= 0x3400 and cp <= 0x4DBF) or
219
+ (cp >= 0x4E00 and cp <= 0x9FFF) or
220
+ (cp >= 0xF900 and cp <= 0xFAFF) or
221
+ (cp >= 0x20000 and cp <= 0x2A6DF) or
222
+ (cp >= 0x2A700 and cp <= 0x2B73F) or
223
+ (cp >= 0x2B740 and cp <= 0x2B81F) or
224
+ (cp >= 0x2B820 and cp <= 0x2CEAF) or
225
+ (cp >= 0x2CEB0 and cp <= 0x2EBEF) or
226
+ (cp >= 0x2EBF0 and cp <= 0x2EE5D) or
227
+ (cp >= 0x2F800 and cp <= 0x2FA1F) or
228
+ // Hiragana + Katakana
229
+ (cp >= 0x3040 and cp <= 0x309F) or
230
+ (cp >= 0x30A0 and cp <= 0x30FF) or
231
+ (cp >= 0x31F0 and cp <= 0x31FF) or
232
+ (cp >= 0xFF66 and cp <= 0xFF9D) or
233
+ // Hangul
234
+ (cp >= 0x1100 and cp <= 0x11FF) or
235
+ (cp >= 0x3130 and cp <= 0x318F) or
236
+ (cp >= 0xA960 and cp <= 0xA97F) or
237
+ (cp >= 0xAC00 and cp <= 0xD7AF) or
238
+ (cp >= 0xD7B0 and cp <= 0xD7FF);
239
+ }
240
+
241
+ inline fn classifyWordClass(cp: u21) WordClass {
242
+ if (cp <= 0x7F) {
243
+ return if (isAsciiWordByte(@intCast(cp))) .ascii_word else .other;
244
+ }
245
+ if (isCjkWordCodepoint(cp)) return .cjk_word;
246
+ return .other;
247
+ }
248
+
249
+ pub inline fn isWordCodepoint(cp: u21) bool {
250
+ return classifyWordClass(cp) != .other;
251
+ }
252
+
253
+ inline fn isCjkAsciiTransition(prev_class: WordClass, curr_class: WordClass) bool {
254
+ return (prev_class == .cjk_word and curr_class == .ascii_word) or
255
+ (prev_class == .ascii_word and curr_class == .cjk_word);
256
+ }
257
+
258
+ // Nothing needed here - using uucode.grapheme.isBreak directly
259
+
260
+ pub fn findWrapBreaks(text: []const u8, result: *WrapBreakResult, width_method: WidthMethod) !void {
261
+ // This function clears previous results and writes fresh break points.
262
+ // Callers should treat `result.breaks` as replaced after the call.
263
+ _ = width_method; // Currently unused, but kept for API consistency
264
+ result.reset();
265
+ const vector_len = 16;
266
+
267
+ var pos: usize = 0;
268
+ var char_offset: u32 = 0;
269
+ var prev_cp: ?u21 = null; // Track previous codepoint for grapheme detection
270
+ var break_state: uucode.grapheme.BreakState = .default;
271
+ // We keep track of the current grapheme so we can add a break at
272
+ // CJK<->ASCII transitions. The break is emitted at the previous grapheme,
273
+ // so callers that add grapheme width land exactly at the run boundary.
274
+ var have_current_grapheme = false;
275
+ var current_grapheme_byte_offset: u32 = 0;
276
+ var current_grapheme_char_offset: u32 = 0;
277
+ var current_grapheme_class: WordClass = .other;
278
+
279
+ while (pos + vector_len <= text.len) {
280
+ const chunk: @Vector(vector_len, u8) = text[pos..][0..vector_len].*;
281
+ const ascii_threshold: @Vector(vector_len, u8) = @splat(0x80);
282
+ const is_non_ascii = chunk >= ascii_threshold;
283
+
284
+ // Fast path: all ASCII
285
+ if (!@reduce(.Or, is_non_ascii)) {
286
+ const first_class = classifyWordClass(text[pos]);
287
+ if (have_current_grapheme and isCjkAsciiTransition(current_grapheme_class, first_class)) {
288
+ try result.breaks.append(result.allocator, .{
289
+ .byte_offset = current_grapheme_byte_offset,
290
+ .char_offset = current_grapheme_char_offset,
291
+ });
292
+ }
293
+
294
+ // Use SIMD to find break characters
295
+ var match_mask: @Vector(vector_len, bool) = @splat(false);
296
+
297
+ // Check whitespace
298
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat(' ')));
299
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('\t')));
300
+
301
+ // Check dashes and slashes
302
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('-')));
303
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('/')));
304
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('\\')));
305
+
306
+ // Check punctuation
307
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('.')));
308
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat(',')));
309
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat(';')));
310
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat(':')));
311
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('!')));
312
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('?')));
313
+
314
+ // Check brackets
315
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('(')));
316
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat(')')));
317
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('[')));
318
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat(']')));
319
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('{')));
320
+ match_mask = match_mask | (chunk == @as(@Vector(vector_len, u8), @splat('}')));
321
+
322
+ // Convert boolean mask to integer bitmask for faster iteration
323
+ var bitmask: u16 = 0;
324
+ inline for (0..vector_len) |i| {
325
+ if (match_mask[i]) {
326
+ bitmask |= @as(u16, 1) << @intCast(i);
327
+ }
328
+ }
329
+
330
+ // Use bit manipulation to extract positions
331
+ while (bitmask != 0) {
332
+ const bit_pos = @ctz(bitmask);
333
+ try result.breaks.append(result.allocator, .{
334
+ .byte_offset = @intCast(pos + bit_pos),
335
+ .char_offset = char_offset + @as(u32, @intCast(bit_pos)),
336
+ });
337
+ bitmask &= bitmask - 1;
338
+ }
339
+
340
+ pos += vector_len;
341
+ const block_start_char_offset = char_offset;
342
+ char_offset += vector_len;
343
+ prev_cp = text[pos - 1]; // Last ASCII char
344
+ break_state = .default;
345
+ have_current_grapheme = true;
346
+ current_grapheme_byte_offset = @intCast(pos - 1);
347
+ current_grapheme_char_offset = block_start_char_offset + (vector_len - 1);
348
+ current_grapheme_class = classifyWordClass(text[pos - 1]);
349
+ continue;
350
+ }
351
+
352
+ // Slow path: mixed ASCII/non-ASCII - need grapheme-aware counting
353
+ var i: usize = 0;
354
+ while (i < vector_len) {
355
+ const b0 = text[pos + i];
356
+ if (b0 < 0x80) {
357
+ const curr_cp: u21 = b0;
358
+
359
+ // Check if this starts a new grapheme cluster
360
+ // Skip invalid/replacement codepoints or codepoints that might be outside the grapheme table range
361
+ const is_break = if (curr_cp == 0xFFFD or curr_cp > 0x10FFFF) true else if (prev_cp) |p| blk: {
362
+ if (p == 0xFFFD or p > 0x10FFFF) break :blk true;
363
+ break :blk uucode.grapheme.isBreak(p, curr_cp, &break_state);
364
+ } else true;
365
+
366
+ if (is_break) {
367
+ const curr_class = classifyWordClass(curr_cp);
368
+ if (have_current_grapheme and isCjkAsciiTransition(current_grapheme_class, curr_class)) {
369
+ try result.breaks.append(result.allocator, .{
370
+ .byte_offset = current_grapheme_byte_offset,
371
+ .char_offset = current_grapheme_char_offset,
372
+ });
373
+ }
374
+ have_current_grapheme = true;
375
+ current_grapheme_byte_offset = @intCast(pos + i);
376
+ current_grapheme_char_offset = char_offset;
377
+ current_grapheme_class = curr_class;
378
+ }
379
+
380
+ if (isAsciiWrapBreak(b0)) {
381
+ try result.breaks.append(result.allocator, .{
382
+ .byte_offset = @intCast(pos + i),
383
+ .char_offset = char_offset,
384
+ });
385
+ }
386
+ i += 1;
387
+ if (is_break) {
388
+ char_offset += 1;
389
+ }
390
+ prev_cp = curr_cp;
391
+ } else {
392
+ const dec = decodeUtf8Unchecked(text, pos + i);
393
+ if (pos + i + dec.len > text.len) break;
394
+ if (pos + i + dec.len > pos + vector_len) break;
395
+
396
+ // Check if this starts a new grapheme cluster
397
+ // Skip invalid/replacement codepoints or codepoints that might be outside the grapheme table range
398
+ const is_break = if (dec.cp == 0xFFFD or dec.cp > 0x10FFFF) true else if (prev_cp) |p| blk: {
399
+ if (p == 0xFFFD or p > 0x10FFFF) break :blk true;
400
+ break :blk uucode.grapheme.isBreak(p, dec.cp, &break_state);
401
+ } else true;
402
+
403
+ if (is_break) {
404
+ const curr_class = classifyWordClass(dec.cp);
405
+ if (have_current_grapheme and isCjkAsciiTransition(current_grapheme_class, curr_class)) {
406
+ try result.breaks.append(result.allocator, .{
407
+ .byte_offset = current_grapheme_byte_offset,
408
+ .char_offset = current_grapheme_char_offset,
409
+ });
410
+ }
411
+ have_current_grapheme = true;
412
+ current_grapheme_byte_offset = @intCast(pos + i);
413
+ current_grapheme_char_offset = char_offset;
414
+ current_grapheme_class = curr_class;
415
+ }
416
+
417
+ if (isUnicodeWrapBreak(dec.cp)) {
418
+ try result.breaks.append(result.allocator, .{
419
+ .byte_offset = @intCast(pos + i),
420
+ .char_offset = char_offset,
421
+ });
422
+ }
423
+ i += dec.len;
424
+ if (is_break) {
425
+ char_offset += 1;
426
+ }
427
+ prev_cp = dec.cp;
428
+ }
429
+ }
430
+ pos += i;
431
+ }
432
+
433
+ // Tail
434
+ var i: usize = pos;
435
+ while (i < text.len) {
436
+ const b0 = text[i];
437
+ if (b0 < 0x80) {
438
+ const curr_cp: u21 = b0;
439
+ const is_break = if (prev_cp) |p| blk: {
440
+ if (p == 0xFFFD or p > 0x10FFFF) break :blk true;
441
+ break :blk uucode.grapheme.isBreak(p, curr_cp, &break_state);
442
+ } else true;
443
+
444
+ if (is_break) {
445
+ const curr_class = classifyWordClass(curr_cp);
446
+ if (have_current_grapheme and isCjkAsciiTransition(current_grapheme_class, curr_class)) {
447
+ try result.breaks.append(result.allocator, .{
448
+ .byte_offset = current_grapheme_byte_offset,
449
+ .char_offset = current_grapheme_char_offset,
450
+ });
451
+ }
452
+ have_current_grapheme = true;
453
+ current_grapheme_byte_offset = @intCast(i);
454
+ current_grapheme_char_offset = char_offset;
455
+ current_grapheme_class = curr_class;
456
+ }
457
+
458
+ if (isAsciiWrapBreak(b0)) {
459
+ try result.breaks.append(result.allocator, .{
460
+ .byte_offset = @intCast(i),
461
+ .char_offset = char_offset,
462
+ });
463
+ }
464
+ i += 1;
465
+ if (is_break) {
466
+ char_offset += 1;
467
+ }
468
+ prev_cp = curr_cp;
469
+ } else {
470
+ const dec = decodeUtf8Unchecked(text, i);
471
+ if (i + dec.len > text.len) break;
472
+
473
+ const is_break = if (dec.cp == 0xFFFD or dec.cp > 0x10FFFF) true else if (prev_cp) |p| blk: {
474
+ if (p == 0xFFFD or p > 0x10FFFF) break :blk true;
475
+ break :blk uucode.grapheme.isBreak(p, dec.cp, &break_state);
476
+ } else true;
477
+
478
+ if (is_break) {
479
+ const curr_class = classifyWordClass(dec.cp);
480
+ if (have_current_grapheme and isCjkAsciiTransition(current_grapheme_class, curr_class)) {
481
+ try result.breaks.append(result.allocator, .{
482
+ .byte_offset = current_grapheme_byte_offset,
483
+ .char_offset = current_grapheme_char_offset,
484
+ });
485
+ }
486
+ have_current_grapheme = true;
487
+ current_grapheme_byte_offset = @intCast(i);
488
+ current_grapheme_char_offset = char_offset;
489
+ current_grapheme_class = curr_class;
490
+ }
491
+
492
+ if (isUnicodeWrapBreak(dec.cp)) {
493
+ try result.breaks.append(result.allocator, .{
494
+ .byte_offset = @intCast(i),
495
+ .char_offset = char_offset,
496
+ });
497
+ }
498
+ i += dec.len;
499
+ if (is_break) {
500
+ char_offset += 1;
501
+ }
502
+ prev_cp = dec.cp;
503
+ }
504
+ }
505
+ }
506
+
507
+ pub fn findTabStops(text: []const u8, result: *TabStopResult) !void {
508
+ result.reset();
509
+ const vector_len = 16;
510
+ const Vec = @Vector(vector_len, u8);
511
+
512
+ const vTab: Vec = @splat('\t');
513
+
514
+ var pos: usize = 0;
515
+
516
+ while (pos + vector_len <= text.len) {
517
+ const chunk: Vec = text[pos..][0..vector_len].*;
518
+ const cmp_tab = chunk == vTab;
519
+
520
+ if (@reduce(.Or, cmp_tab)) {
521
+ var i: usize = 0;
522
+ while (i < vector_len) : (i += 1) {
523
+ if (text[pos + i] == '\t') {
524
+ try result.positions.append(result.allocator, pos + i);
525
+ }
526
+ }
527
+ }
528
+ pos += vector_len;
529
+ }
530
+
531
+ while (pos < text.len) : (pos += 1) {
532
+ if (text[pos] == '\t') {
533
+ try result.positions.append(result.allocator, pos);
534
+ }
535
+ }
536
+ }
537
+
538
+ pub fn findLineBreaks(text: []const u8, result: *LineBreakResult) !void {
539
+ result.reset();
540
+ const vector_len = 16; // Use 16-byte vectors (SSE2/NEON compatible)
541
+ const Vec = @Vector(vector_len, u8);
542
+
543
+ // Prepare vector constants for '\n' and '\r'
544
+ const vNL: Vec = @splat('\n');
545
+ const vCR: Vec = @splat('\r');
546
+
547
+ var pos: usize = 0;
548
+ var prev_was_cr = false; // Track if previous chunk ended with \r
549
+
550
+ // Process full vector chunks
551
+ while (pos + vector_len <= text.len) {
552
+ const chunk: Vec = text[pos..][0..vector_len].*;
553
+ const cmp_nl = chunk == vNL;
554
+ const cmp_cr = chunk == vCR;
555
+
556
+ // Check if any newline or CR found
557
+ if (@reduce(.Or, cmp_nl) or @reduce(.Or, cmp_cr)) {
558
+ // Found a match, process this chunk
559
+ var i: usize = 0;
560
+ while (i < vector_len) : (i += 1) {
561
+ const absolute_index = pos + i;
562
+ const b = text[absolute_index];
563
+ if (b == '\n') {
564
+ // Skip if this is the \n part of a CRLF split across chunks
565
+ if (i == 0 and prev_was_cr) {
566
+ prev_was_cr = false;
567
+ continue;
568
+ }
569
+ // Check if this is part of CRLF
570
+ const kind: LineBreakKind = if (absolute_index > 0 and text[absolute_index - 1] == '\r') .CRLF else .LF;
571
+ try result.breaks.append(result.allocator, .{ .pos = absolute_index, .kind = kind });
572
+ } else if (b == '\r') {
573
+ // Check for CRLF
574
+ if (absolute_index + 1 < text.len and text[absolute_index + 1] == '\n') {
575
+ try result.breaks.append(result.allocator, .{ .pos = absolute_index + 1, .kind = .CRLF });
576
+ i += 1; // Skip the \n in next iteration
577
+ } else {
578
+ try result.breaks.append(result.allocator, .{ .pos = absolute_index, .kind = .CR });
579
+ }
580
+ }
581
+ }
582
+ // Update prev_was_cr for next chunk
583
+ prev_was_cr = (text[pos + vector_len - 1] == '\r');
584
+ } else {
585
+ prev_was_cr = false;
586
+ }
587
+ pos += vector_len;
588
+ }
589
+
590
+ // Handle remaining bytes with scalar code
591
+ while (pos < text.len) : (pos += 1) {
592
+ const b = text[pos];
593
+ if (b == '\n') {
594
+ // Handle CRLF split at chunk boundary
595
+ if (pos > 0 and text[pos - 1] == '\r') {
596
+ // Already recorded at pos - 1 or will be skipped
597
+ if (prev_was_cr) {
598
+ prev_was_cr = false;
599
+ continue;
600
+ }
601
+ }
602
+ const kind: LineBreakKind = if (pos > 0 and text[pos - 1] == '\r') .CRLF else .LF;
603
+ try result.breaks.append(result.allocator, .{ .pos = pos, .kind = kind });
604
+ } else if (b == '\r') {
605
+ if (pos + 1 < text.len and text[pos + 1] == '\n') {
606
+ try result.breaks.append(result.allocator, .{ .pos = pos + 1, .kind = .CRLF });
607
+ pos += 1;
608
+ } else {
609
+ try result.breaks.append(result.allocator, .{ .pos = pos, .kind = .CR });
610
+ }
611
+ }
612
+ prev_was_cr = false;
613
+ }
614
+ }
615
+
616
+ pub const WrapByWidthResult = struct {
617
+ byte_offset: u32,
618
+ grapheme_count: u32,
619
+ columns_used: u32,
620
+ };
621
+
622
+ pub const PosByWidthResult = struct {
623
+ byte_offset: u32,
624
+ grapheme_count: u32,
625
+ columns_used: u32,
626
+ };
627
+
628
+ pub inline fn eastAsianWidth(cp: u21) u32 {
629
+ if (cp > 0x10FFFF) return 0;
630
+ const eaw = uucode.get(.east_asian_width, cp);
631
+ const width = eawToWidth(cp, eaw);
632
+ return if (width > 0) @intCast(width) else 0;
633
+ }
634
+
635
+ /// Calculate width from east asian width property and Unicode properties
636
+ /// Returns -1 for control characters (they don't contribute to width)
637
+ inline fn eawToWidth(cp: u21, eaw: uucode.types.EastAsianWidth) i16 {
638
+ if (cp == 0) return 0;
639
+ if (cp < 32 or (cp >= 0x7F and cp < 0xA0)) return -1;
640
+
641
+ const gc = uucode.get(.general_category, cp);
642
+ switch (gc) {
643
+ .mark_nonspacing, .mark_spacing_combining, .mark_enclosing => return 0,
644
+ else => {},
645
+ }
646
+
647
+ if (cp == 0x200B) return 0;
648
+ if (cp == 0x200C) return 0;
649
+ if (cp == 0x200D) return 0;
650
+ if (cp == 0x2060) return 0;
651
+ if (cp == 0x034F) return 0;
652
+ if (cp == 0xFEFF) return 0;
653
+ if (cp >= 0x180B and cp <= 0x180D) return 0;
654
+ if (cp >= 0xFE00 and cp <= 0xFE0F) return 0;
655
+ if (cp >= 0xE0100 and cp <= 0xE01EF) return 0;
656
+
657
+ if (eaw == .fullwidth or eaw == .wide) return 2;
658
+
659
+ if (cp >= 0x1F000 and cp <= 0x1F02B) return 2;
660
+ if (cp >= 0x1F030 and cp <= 0x1F093) return 2;
661
+ if (cp >= 0x1F0A0 and cp <= 0x1F0AE) return 2;
662
+ if (cp >= 0x1F0B1 and cp <= 0x1F0BF) return 2;
663
+ if (cp >= 0x1F0C1 and cp <= 0x1F0CF) return 2;
664
+ if (cp >= 0x1F0D1 and cp <= 0x1F0F5) return 2;
665
+
666
+ if (cp == 0x231A or cp == 0x231B) return 2;
667
+ if (cp == 0x2329 or cp == 0x232A) return 2;
668
+ if (cp >= 0x23E9 and cp <= 0x23EC) return 2;
669
+ if (cp == 0x23F0 or cp == 0x23F3) return 2;
670
+ if (cp >= 0x25FD and cp <= 0x25FE) return 2;
671
+
672
+ if (cp >= 0x2614 and cp <= 0x2615) return 2;
673
+ if (cp == 0x2622 or cp == 0x2623) return 2;
674
+ if (cp >= 0x2630 and cp <= 0x2637) return 2;
675
+ if (cp >= 0x2648 and cp <= 0x2653) return 2;
676
+ if (cp == 0x267F or cp == 0x2693 or cp == 0x269B) return 2;
677
+ if (cp >= 0x26AA and cp <= 0x26AB) return 2;
678
+ if (cp >= 0x26BD and cp <= 0x26BE) return 2;
679
+ if (cp >= 0x26C4 and cp <= 0x26C5) return 2;
680
+ if (cp == 0x26CE or cp == 0x26D1 or cp == 0x26D4) return 2;
681
+ if (cp == 0x26EA or cp == 0x26F2 or cp == 0x26F3) return 2;
682
+ if (cp == 0x26F5 or cp == 0x26FA or cp == 0x26FD) return 2;
683
+
684
+ if (cp == 0x203C or cp == 0x2049) return 2;
685
+ if (cp == 0x2705 or cp >= 0x270A and cp <= 0x270B) return 2;
686
+ if (cp == 0x2728 or cp == 0x274C or cp == 0x274E) return 2;
687
+ if (cp >= 0x2753 and cp <= 0x2755) return 2;
688
+ if (cp == 0x2757) return 2;
689
+ if (cp >= 0x2760 and cp <= 0x2767) return 2;
690
+ if (cp >= 0x2795 and cp <= 0x2797) return 2;
691
+ if (cp == 0x27B0 or cp == 0x27BF) return 2;
692
+ if (cp >= 0x2B1B and cp <= 0x2B1C) return 2;
693
+ if (cp >= 0x2B50 and cp <= 0x2B50) return 2;
694
+ if (cp >= 0x2B55 and cp <= 0x2B55) return 2;
695
+
696
+ if (cp >= 0x1F300 and cp <= 0x1F320) return 2;
697
+ if (cp >= 0x1F32D and cp <= 0x1F335) return 2;
698
+ if (cp >= 0x1F337 and cp <= 0x1F37C) return 2;
699
+ if (cp >= 0x1F37E and cp <= 0x1F393) return 2;
700
+ if (cp >= 0x1F3A0 and cp <= 0x1F3CA) return 2;
701
+ if (cp >= 0x1F3CF and cp <= 0x1F3D3) return 2;
702
+ if (cp >= 0x1F3E0 and cp <= 0x1F3F0) return 2;
703
+ if (cp == 0x1F3F4) return 2;
704
+ if (cp >= 0x1F3F8 and cp <= 0x1F3FF) return 2;
705
+ if (cp >= 0x1F400 and cp <= 0x1F43E) return 2;
706
+ if (cp == 0x1F440) return 2;
707
+ if (cp >= 0x1F442 and cp <= 0x1F4FC) return 2;
708
+ if (cp >= 0x1F4FF and cp <= 0x1F6C5) return 2;
709
+ if (cp == 0x1F6CC) return 2;
710
+ if (cp >= 0x1F6D0 and cp <= 0x1F6D2) return 2;
711
+ if (cp >= 0x1F6D5 and cp <= 0x1F6D7) return 2;
712
+ if (cp >= 0x1F6DC and cp <= 0x1F6DF) return 2;
713
+ if (cp >= 0x1F6EB and cp <= 0x1F6EC) return 2;
714
+ if (cp >= 0x1F6F4 and cp <= 0x1F6FC) return 2;
715
+ if (cp >= 0x1F700 and cp <= 0x1F773) return 2;
716
+ if (cp >= 0x1F780 and cp <= 0x1F7D8) return 2;
717
+ if (cp >= 0x1F7E0 and cp <= 0x1F7EB) return 2;
718
+ if (cp >= 0x1F800 and cp <= 0x1F80B) return 2;
719
+ if (cp >= 0x1F810 and cp <= 0x1F847) return 2;
720
+ if (cp >= 0x1F850 and cp <= 0x1F859) return 2;
721
+ if (cp >= 0x1F860 and cp <= 0x1F887) return 2;
722
+ if (cp >= 0x1F890 and cp <= 0x1F8AD) return 2;
723
+ if (cp >= 0x1F8B0 and cp <= 0x1F8B1) return 2;
724
+ if (cp >= 0x1F90C and cp <= 0x1F93A) return 2;
725
+ if (cp >= 0x1F93C and cp <= 0x1F945) return 2;
726
+ if (cp >= 0x1F947 and cp <= 0x1FA53) return 2;
727
+ if (cp >= 0x1FA60 and cp <= 0x1FA6D) return 2;
728
+ if (cp >= 0x1FA70 and cp <= 0x1FA74) return 2;
729
+ if (cp >= 0x1FA78 and cp <= 0x1FA7C) return 2;
730
+ if (cp >= 0x1FA80 and cp <= 0x1FA86) return 2;
731
+ if (cp >= 0x1FA90 and cp <= 0x1FAAC) return 2;
732
+ if (cp >= 0x1FAB0 and cp <= 0x1FABA) return 2;
733
+ if (cp >= 0x1FAC0 and cp <= 0x1FAC5) return 2;
734
+ if (cp >= 0x1FAD0 and cp <= 0x1FAD9) return 2;
735
+ if (cp >= 0x1FAE0 and cp <= 0x1FAE7) return 2;
736
+ if (cp >= 0x1FAF0 and cp <= 0x1FAF8) return 2;
737
+
738
+ return 1;
739
+ }
740
+
741
+ /// Calculate the display width of a byte in columns
742
+ /// Used for ASCII-only fast paths
743
+ inline fn asciiCharWidth(byte: u8, tab_width: u8) u32 {
744
+ if (byte == '\t') {
745
+ return tab_width;
746
+ } else if (byte >= 32 and byte <= 126) {
747
+ return 1;
748
+ }
749
+ return 0;
750
+ }
751
+
752
+ /// Calculate the display width of a character (byte or codepoint) in columns
753
+ inline fn charWidth(byte: u8, codepoint: u21, tab_width: u8) u32 {
754
+ if (byte == '\t') {
755
+ return tab_width;
756
+ } else if (byte < 0x80 and byte >= 32 and byte <= 126) {
757
+ return 1;
758
+ } else if (byte >= 0x80) {
759
+ if (codepoint > 0x10FFFF) return 0;
760
+ const eaw = uucode.get(.east_asian_width, codepoint);
761
+ const w = eawToWidth(codepoint, eaw);
762
+ return if (w > 0) @intCast(w) else 0;
763
+ }
764
+ return 0;
765
+ }
766
+
767
+ /// Check if a codepoint is valid for grapheme break detection
768
+ inline fn isValidCodepoint(cp: u21) bool {
769
+ return cp != 0xFFFD and cp <= 0x10FFFF;
770
+ }
771
+
772
+ /// Check if there's a grapheme break between two codepoints
773
+ /// - wcwidth mode: use Unicode grapheme clustering for proper rendering,
774
+ /// but calculate width using wcwidth (sum of codepoint widths)
775
+ /// - no_zwj mode: use grapheme breaks but treat ZWJ as a break (ignore joining)
776
+ /// - unicode mode: use standard grapheme cluster segmentation
777
+ inline fn isGraphemeBreak(prev_cp: ?u21, curr_cp: u21, break_state: *uucode.grapheme.BreakState, width_method: WidthMethod) bool {
778
+ // wcwidth mode uses Unicode grapheme clustering for proper rendering
779
+ // (ZWJ sequences, skin tone modifiers stay together), but width is
780
+ // calculated using wcwidth semantics (sum of codepoint widths)
781
+ if (width_method == .wcwidth) {
782
+ if (prev_cp == null) return true;
783
+
784
+ if (!isValidCodepoint(curr_cp)) return true;
785
+ if (!isValidCodepoint(prev_cp.?)) return true;
786
+ return uucode.grapheme.isBreak(prev_cp.?, curr_cp, break_state);
787
+ }
788
+
789
+ if (!isValidCodepoint(curr_cp)) return true;
790
+
791
+ // In no_zwj mode, treat ZWJ (U+200D) as NOT joining characters
792
+ // When we see ZWJ after a character, it's part of that character's grapheme
793
+ // But when we see a character after ZWJ, it starts a new grapheme
794
+ if (width_method == .no_zwj) {
795
+ const ZWJ: u21 = 0x200D;
796
+ if (prev_cp) |p| {
797
+ // If previous was ZWJ, current starts a new grapheme
798
+ // Don't call uucode.grapheme.isBreak because it will say no break
799
+ if (p == ZWJ) {
800
+ // Reset break state since we're forcing a break
801
+ break_state.* = .default;
802
+ return true;
803
+ }
804
+ }
805
+ // If current is ZWJ, don't break yet - it's part of previous grapheme
806
+ // (will have width 0 anyway)
807
+ }
808
+
809
+ if (prev_cp) |p| {
810
+ if (!isValidCodepoint(p)) return true;
811
+ return uucode.grapheme.isBreak(p, curr_cp, break_state);
812
+ }
813
+ return true;
814
+ }
815
+
816
+ /// State for accumulating grapheme cluster width
817
+ const GraphemeWidthState = struct {
818
+ width: u32 = 0,
819
+ has_width: bool = false,
820
+ is_regional_indicator_pair: bool = false,
821
+ has_vs16: bool = false,
822
+ has_indic_virama: bool = false,
823
+ width_method: WidthMethod,
824
+
825
+ /// Initialize state with the first codepoint of a grapheme cluster
826
+ inline fn init(first_cp: u21, first_width: u32, width_method: WidthMethod) GraphemeWidthState {
827
+ return .{
828
+ .width = first_width,
829
+ .has_width = (first_width > 0),
830
+ .is_regional_indicator_pair = (first_cp >= 0x1F1E6 and first_cp <= 0x1F1FF),
831
+ .has_vs16 = false,
832
+ .has_indic_virama = false,
833
+ .width_method = width_method,
834
+ };
835
+ }
836
+
837
+ /// Add a codepoint to the current grapheme cluster
838
+ inline fn addCodepoint(self: *GraphemeWidthState, cp: u21, cp_width: u32) void {
839
+ // wcwidth mode: sum all codepoint widths (tmux-style)
840
+ if (self.width_method == .wcwidth) {
841
+ const eaw = uucode.get(.east_asian_width, cp);
842
+ const w = eawToWidth(cp, eaw);
843
+ if (w > 0) {
844
+ self.width += @intCast(w);
845
+ self.has_width = true;
846
+ }
847
+ return;
848
+ }
849
+
850
+ // unicode and no_zwj modes: use grapheme-aware width
851
+ const is_ri = (cp >= 0x1F1E6 and cp <= 0x1F1FF);
852
+ const is_vs16 = (cp == 0xFE0F); // Variation Selector-16 (emoji presentation)
853
+
854
+ const gc = uucode.get(.general_category, cp);
855
+ const is_virama = gc == .mark_nonspacing;
856
+
857
+ const is_devanagari_ra = (cp == 0x0930);
858
+
859
+ const is_devanagari_base = (cp >= 0x0915 and cp <= 0x0939) or (cp >= 0x0958 and cp <= 0x095F);
860
+
861
+ if (is_vs16) {
862
+ self.has_vs16 = true;
863
+ if (self.has_width and self.width == 1) {
864
+ self.width = 2;
865
+ }
866
+ return;
867
+ }
868
+
869
+ if (is_virama) {
870
+ self.has_indic_virama = true;
871
+ return;
872
+ }
873
+
874
+ if (self.is_regional_indicator_pair and is_ri) {
875
+ self.width += cp_width;
876
+ self.has_width = true;
877
+ } else if (!self.has_width and cp_width > 0) {
878
+ self.width = cp_width;
879
+ self.has_width = true;
880
+ } else if (self.has_width and self.has_indic_virama and is_devanagari_base and cp_width > 0) {
881
+ if (!is_devanagari_ra) {
882
+ self.width += cp_width;
883
+ }
884
+ self.has_indic_virama = false;
885
+ }
886
+ }
887
+ };
888
+
889
+ const ClusterState = struct {
890
+ columns_used: u32,
891
+ grapheme_count: u32,
892
+ cluster_width: u32,
893
+ cluster_start: usize,
894
+ prev_cp: ?u21,
895
+ break_state: uucode.grapheme.BreakState,
896
+ width_state: GraphemeWidthState,
897
+ width_method: WidthMethod,
898
+ cluster_started: bool,
899
+
900
+ fn init(width_method: WidthMethod) ClusterState {
901
+ const dummy_width_state = GraphemeWidthState.init(0, 0, width_method);
902
+ return .{
903
+ .columns_used = 0,
904
+ .grapheme_count = 0,
905
+ .cluster_width = 0,
906
+ .cluster_start = 0,
907
+ .prev_cp = null,
908
+ .break_state = .default,
909
+ .width_state = dummy_width_state,
910
+ .width_method = width_method,
911
+ .cluster_started = false,
912
+ };
913
+ }
914
+ };
915
+
916
+ /// Handle grapheme cluster boundary when wrapping by width (stops BEFORE exceeding limit)
917
+ /// Returns true if we should stop (limit exceeded)
918
+ inline fn handleClusterForWrap(
919
+ state: *ClusterState,
920
+ is_break: bool,
921
+ new_cluster_start: usize,
922
+ max_columns: u32,
923
+ ) bool {
924
+ if (is_break) {
925
+ if (state.prev_cp != null) {
926
+ if (state.columns_used + state.cluster_width > max_columns) {
927
+ return true; // Signal to stop
928
+ }
929
+ state.columns_used += state.cluster_width;
930
+ state.grapheme_count += 1;
931
+ }
932
+ state.cluster_width = 0;
933
+ state.cluster_start = new_cluster_start;
934
+ state.cluster_started = false;
935
+ }
936
+ return false;
937
+ }
938
+
939
+ /// Handle grapheme cluster boundary when finding position (snaps to grapheme boundaries)
940
+ /// Returns true if we should stop
941
+ ///
942
+ /// Snapping behavior:
943
+ /// - include_start_before=true (for selection end): Include graphemes that START at or before max_columns
944
+ /// If max_columns=3 and grapheme occupies columns [2-3], include it (starts at 2 <= 3)
945
+ /// This snaps forward to include the whole grapheme even if max_columns points to its middle
946
+ /// - include_start_before=false (for selection start): Only include graphemes that END before max_columns
947
+ /// If max_columns=3 and grapheme occupies columns [2-3], exclude it (ends at 4 > 3)
948
+ /// This snaps backward to exclude wide graphemes that would cross max_columns
949
+ inline fn handleClusterForPos(
950
+ state: *ClusterState,
951
+ is_break: bool,
952
+ new_cluster_start: usize,
953
+ max_columns: u32,
954
+ include_start_before: bool,
955
+ ) bool {
956
+ if (is_break) {
957
+ if (state.prev_cp != null) {
958
+ const cluster_start_col = state.columns_used;
959
+ const cluster_end_col = state.columns_used + state.cluster_width;
960
+
961
+ if (include_start_before) {
962
+ if (cluster_start_col >= max_columns) {
963
+ return true;
964
+ }
965
+ state.columns_used = cluster_end_col;
966
+ state.grapheme_count += 1;
967
+ } else {
968
+ if (cluster_end_col > max_columns) {
969
+ return true; // Signal to stop (don't include this grapheme)
970
+ }
971
+ state.columns_used = cluster_end_col;
972
+ }
973
+ }
974
+ state.cluster_width = 0;
975
+ state.cluster_start = new_cluster_start;
976
+ state.cluster_started = false;
977
+ }
978
+ return false;
979
+ }
980
+
981
+ /// Find wrap position by width - proxy function that dispatches based on width_method
982
+ pub fn findWrapPosByWidth(
983
+ text: []const u8,
984
+ max_columns: u32,
985
+ tab_width: u8,
986
+ isASCIIOnly: bool,
987
+ width_method: WidthMethod,
988
+ ) WrapByWidthResult {
989
+ switch (width_method) {
990
+ .unicode, .no_zwj => return findWrapPosByWidthUnicode(text, max_columns, tab_width, isASCIIOnly, width_method),
991
+ .wcwidth => return findWrapPosByWidthWCWidth(text, max_columns, tab_width, isASCIIOnly),
992
+ }
993
+ }
994
+
995
+ /// Find wrap position by width using Unicode grapheme cluster segmentation
996
+ fn findWrapPosByWidthUnicode(
997
+ text: []const u8,
998
+ max_columns: u32,
999
+ tab_width: u8,
1000
+ isASCIIOnly: bool,
1001
+ width_method: WidthMethod,
1002
+ ) WrapByWidthResult {
1003
+ if (text.len == 0 or max_columns == 0) {
1004
+ return .{ .byte_offset = 0, .grapheme_count = 0, .columns_used = 0 };
1005
+ }
1006
+
1007
+ // ASCII-only fast path
1008
+ if (isASCIIOnly) {
1009
+ if (max_columns >= text.len) {
1010
+ return .{ .byte_offset = @intCast(text.len), .grapheme_count = @intCast(text.len), .columns_used = @intCast(text.len) };
1011
+ } else {
1012
+ return .{ .byte_offset = max_columns, .grapheme_count = max_columns, .columns_used = max_columns };
1013
+ }
1014
+ }
1015
+
1016
+ const vector_len = 16;
1017
+ var pos: usize = 0;
1018
+ var state = ClusterState.init(width_method);
1019
+
1020
+ while (pos + vector_len <= text.len) {
1021
+ const chunk: @Vector(vector_len, u8) = text[pos..][0..vector_len].*;
1022
+ const ascii_threshold: @Vector(vector_len, u8) = @splat(0x80);
1023
+ const is_non_ascii = chunk >= ascii_threshold;
1024
+
1025
+ if (!@reduce(.Or, is_non_ascii)) {
1026
+ // All ASCII
1027
+ var i: usize = 0;
1028
+ while (i < vector_len) : (i += 1) {
1029
+ const b = text[pos + i];
1030
+ const curr_cp: u21 = b;
1031
+ const is_break = isGraphemeBreak(state.prev_cp, curr_cp, &state.break_state, state.width_method);
1032
+
1033
+ if (handleClusterForWrap(&state, is_break, pos + i, max_columns)) {
1034
+ return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1035
+ }
1036
+
1037
+ const cp_width = asciiCharWidth(b, tab_width);
1038
+ if (!state.cluster_started) {
1039
+ state.width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1040
+ state.cluster_width = cp_width;
1041
+ state.cluster_started = true;
1042
+ } else {
1043
+ state.width_state.addCodepoint(curr_cp, cp_width);
1044
+ state.cluster_width = state.width_state.width;
1045
+ }
1046
+ state.prev_cp = curr_cp;
1047
+ }
1048
+ pos += vector_len;
1049
+ continue;
1050
+ }
1051
+
1052
+ // Mixed ASCII/non-ASCII - process rest of chunk
1053
+ var i: usize = 0;
1054
+ while (i < vector_len and pos + i < text.len) {
1055
+ const b0 = text[pos + i];
1056
+ const curr_cp: u21 = if (b0 < 0x80) b0 else decodeUtf8Unchecked(text, pos + i).cp;
1057
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos + i).len;
1058
+
1059
+ if (pos + i + cp_len > text.len) break;
1060
+
1061
+ const is_break = isGraphemeBreak(state.prev_cp, curr_cp, &state.break_state, state.width_method);
1062
+
1063
+ if (handleClusterForWrap(&state, is_break, pos + i, max_columns)) {
1064
+ return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1065
+ }
1066
+
1067
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1068
+ if (!state.cluster_started) {
1069
+ state.width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1070
+ state.cluster_width = cp_width;
1071
+ state.cluster_started = true;
1072
+ } else {
1073
+ state.width_state.addCodepoint(curr_cp, cp_width);
1074
+ state.cluster_width = state.width_state.width;
1075
+ }
1076
+ state.prev_cp = curr_cp;
1077
+ i += cp_len;
1078
+ }
1079
+ pos += i; // Advance by how much we actually processed
1080
+ }
1081
+
1082
+ // Tail
1083
+ while (pos < text.len) {
1084
+ const b0 = text[pos];
1085
+ const curr_cp: u21 = if (b0 < 0x80) b0 else decodeUtf8Unchecked(text, pos).cp;
1086
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1087
+
1088
+ const is_break = isGraphemeBreak(state.prev_cp, curr_cp, &state.break_state, state.width_method);
1089
+
1090
+ if (handleClusterForWrap(&state, is_break, pos, max_columns)) {
1091
+ return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1092
+ }
1093
+
1094
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1095
+ if (!state.cluster_started) {
1096
+ state.width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1097
+ state.cluster_width = cp_width;
1098
+ state.cluster_started = true;
1099
+ } else {
1100
+ state.width_state.addCodepoint(curr_cp, cp_width);
1101
+ state.cluster_width = state.width_state.width;
1102
+ }
1103
+ state.prev_cp = curr_cp;
1104
+ pos += cp_len;
1105
+ }
1106
+
1107
+ // Final cluster
1108
+ if (state.prev_cp != null and state.cluster_width > 0) {
1109
+ if (state.columns_used + state.cluster_width > max_columns) {
1110
+ return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1111
+ }
1112
+ state.columns_used += state.cluster_width;
1113
+ state.grapheme_count += 1;
1114
+ }
1115
+
1116
+ return .{ .byte_offset = @intCast(text.len), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1117
+ }
1118
+
1119
+ /// Find wrap position by width using wcwidth-style codepoint-by-codepoint processing
1120
+ fn findWrapPosByWidthWCWidth(
1121
+ text: []const u8,
1122
+ max_columns: u32,
1123
+ tab_width: u8,
1124
+ isASCIIOnly: bool,
1125
+ ) WrapByWidthResult {
1126
+ if (text.len == 0 or max_columns == 0) {
1127
+ return .{ .byte_offset = 0, .grapheme_count = 0, .columns_used = 0 };
1128
+ }
1129
+
1130
+ // ASCII-only fast path
1131
+ if (isASCIIOnly) {
1132
+ if (max_columns >= text.len) {
1133
+ return .{ .byte_offset = @intCast(text.len), .grapheme_count = @intCast(text.len), .columns_used = @intCast(text.len) };
1134
+ } else {
1135
+ return .{ .byte_offset = max_columns, .grapheme_count = max_columns, .columns_used = max_columns };
1136
+ }
1137
+ }
1138
+
1139
+ // Unicode path - process each codepoint independently
1140
+ var pos: usize = 0;
1141
+ var columns_used: u32 = 0;
1142
+ var codepoint_count: u32 = 0;
1143
+
1144
+ while (pos < text.len) {
1145
+ const b0 = text[pos];
1146
+ const curr_cp: u21 = if (b0 < 0x80) b0 else blk: {
1147
+ const dec = decodeUtf8Unchecked(text, pos);
1148
+ if (pos + dec.len > text.len) break :blk 0xFFFD;
1149
+ break :blk dec.cp;
1150
+ };
1151
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1152
+
1153
+ if (pos + cp_len > text.len) break;
1154
+
1155
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1156
+
1157
+ // In wcwidth mode, stop if we've already used max_columns
1158
+ // (don't continue adding zero-width chars after reaching limit)
1159
+ if (columns_used >= max_columns) {
1160
+ return .{ .byte_offset = @intCast(pos), .grapheme_count = codepoint_count, .columns_used = columns_used };
1161
+ }
1162
+
1163
+ // Stop if adding this codepoint would exceed max_columns
1164
+ if (columns_used + cp_width > max_columns) {
1165
+ return .{ .byte_offset = @intCast(pos), .grapheme_count = codepoint_count, .columns_used = columns_used };
1166
+ }
1167
+
1168
+ columns_used += cp_width;
1169
+ codepoint_count += 1;
1170
+ pos += cp_len;
1171
+ }
1172
+
1173
+ return .{ .byte_offset = @intCast(text.len), .grapheme_count = codepoint_count, .columns_used = columns_used };
1174
+ }
1175
+
1176
+ /// Find position by column width - proxy function that dispatches based on width_method
1177
+ /// - If include_start_before: include graphemes that START before max_columns (snap forward for selection end)
1178
+ /// This ensures that if max_columns points to the middle of a width=2 grapheme, we include the whole grapheme
1179
+ /// - If !include_start_before: exclude graphemes that START at or after max_columns (snap backward for selection start)
1180
+ /// This ensures that if max_columns points to the middle of a width=2 grapheme, we snap back to exclude it
1181
+ pub fn findPosByWidth(
1182
+ text: []const u8,
1183
+ max_columns: u32,
1184
+ tab_width: u8,
1185
+ isASCIIOnly: bool,
1186
+ include_start_before: bool,
1187
+ width_method: WidthMethod,
1188
+ ) PosByWidthResult {
1189
+ switch (width_method) {
1190
+ .unicode, .no_zwj => return findPosByWidthUnicode(text, max_columns, tab_width, isASCIIOnly, include_start_before, width_method),
1191
+ .wcwidth => return findPosByWidthWCWidth(text, max_columns, tab_width, isASCIIOnly, include_start_before),
1192
+ }
1193
+ }
1194
+
1195
+ /// Find position by column width using Unicode grapheme cluster segmentation
1196
+ fn findPosByWidthUnicode(
1197
+ text: []const u8,
1198
+ max_columns: u32,
1199
+ tab_width: u8,
1200
+ isASCIIOnly: bool,
1201
+ include_start_before: bool,
1202
+ width_method: WidthMethod,
1203
+ ) PosByWidthResult {
1204
+ if (text.len == 0 or max_columns == 0) {
1205
+ return .{ .byte_offset = 0, .grapheme_count = 0, .columns_used = 0 };
1206
+ }
1207
+
1208
+ // ASCII-only fast path
1209
+ if (isASCIIOnly) {
1210
+ if (max_columns >= text.len) {
1211
+ return .{ .byte_offset = @intCast(text.len), .grapheme_count = @intCast(text.len), .columns_used = @intCast(text.len) };
1212
+ } else {
1213
+ return .{ .byte_offset = max_columns, .grapheme_count = max_columns, .columns_used = max_columns };
1214
+ }
1215
+ }
1216
+
1217
+ const vector_len = 16;
1218
+ var pos: usize = 0;
1219
+ var state = ClusterState.init(width_method);
1220
+
1221
+ while (pos + vector_len <= text.len) {
1222
+ const chunk: @Vector(vector_len, u8) = text[pos..][0..vector_len].*;
1223
+ const ascii_threshold: @Vector(vector_len, u8) = @splat(0x80);
1224
+ const is_non_ascii = chunk >= ascii_threshold;
1225
+
1226
+ if (!@reduce(.Or, is_non_ascii)) {
1227
+ // All ASCII
1228
+ var i: usize = 0;
1229
+ while (i < vector_len) : (i += 1) {
1230
+ const b = text[pos + i];
1231
+ const curr_cp: u21 = b;
1232
+ const is_break = isGraphemeBreak(state.prev_cp, curr_cp, &state.break_state, state.width_method);
1233
+
1234
+ if (handleClusterForPos(&state, is_break, pos + i, max_columns, include_start_before)) {
1235
+ return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1236
+ }
1237
+
1238
+ const cp_width = asciiCharWidth(b, tab_width);
1239
+ if (!state.cluster_started) {
1240
+ state.width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1241
+ state.cluster_width = cp_width;
1242
+ state.cluster_started = true;
1243
+ } else {
1244
+ state.width_state.addCodepoint(curr_cp, cp_width);
1245
+ state.cluster_width = state.width_state.width;
1246
+ }
1247
+ state.prev_cp = curr_cp;
1248
+ }
1249
+ pos += vector_len;
1250
+ continue;
1251
+ }
1252
+
1253
+ // Mixed ASCII/non-ASCII - process rest of chunk
1254
+ var i: usize = 0;
1255
+ while (i < vector_len and pos + i < text.len) {
1256
+ const b0 = text[pos + i];
1257
+ const curr_cp: u21 = if (b0 < 0x80) b0 else decodeUtf8Unchecked(text, pos + i).cp;
1258
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos + i).len;
1259
+
1260
+ if (pos + i + cp_len > text.len) break;
1261
+
1262
+ const is_break = isGraphemeBreak(state.prev_cp, curr_cp, &state.break_state, state.width_method);
1263
+
1264
+ if (handleClusterForPos(&state, is_break, pos + i, max_columns, include_start_before)) {
1265
+ return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1266
+ }
1267
+
1268
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1269
+ if (!state.cluster_started) {
1270
+ state.width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1271
+ state.cluster_width = cp_width;
1272
+ state.cluster_started = true;
1273
+ } else {
1274
+ state.width_state.addCodepoint(curr_cp, cp_width);
1275
+ state.cluster_width = state.width_state.width;
1276
+ }
1277
+ state.prev_cp = curr_cp;
1278
+ i += cp_len;
1279
+ }
1280
+ pos += i; // Advance by how much we actually processed
1281
+ }
1282
+
1283
+ // Tail
1284
+ while (pos < text.len) {
1285
+ const b0 = text[pos];
1286
+ const curr_cp: u21 = if (b0 < 0x80) b0 else decodeUtf8Unchecked(text, pos).cp;
1287
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1288
+
1289
+ const is_break = isGraphemeBreak(state.prev_cp, curr_cp, &state.break_state, state.width_method);
1290
+
1291
+ if (handleClusterForPos(&state, is_break, pos, max_columns, include_start_before)) {
1292
+ return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1293
+ }
1294
+
1295
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1296
+ if (!state.cluster_started) {
1297
+ state.width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1298
+ state.cluster_width = cp_width;
1299
+ state.cluster_started = true;
1300
+ } else {
1301
+ state.width_state.addCodepoint(curr_cp, cp_width);
1302
+ state.cluster_width = state.width_state.width;
1303
+ }
1304
+ state.prev_cp = curr_cp;
1305
+ pos += cp_len;
1306
+ }
1307
+
1308
+ // Final cluster
1309
+ if (state.prev_cp != null and state.cluster_width > 0) {
1310
+ if (state.columns_used >= max_columns) {
1311
+ return .{ .byte_offset = @intCast(state.cluster_start), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1312
+ }
1313
+ state.columns_used += state.cluster_width;
1314
+ if (include_start_before) {
1315
+ state.grapheme_count += 1;
1316
+ }
1317
+ }
1318
+
1319
+ return .{ .byte_offset = @intCast(text.len), .grapheme_count = state.grapheme_count, .columns_used = state.columns_used };
1320
+ }
1321
+
1322
+ /// Find position by column width using wcwidth-style codepoint-by-codepoint processing
1323
+ fn findPosByWidthWCWidth(
1324
+ text: []const u8,
1325
+ max_columns: u32,
1326
+ tab_width: u8,
1327
+ isASCIIOnly: bool,
1328
+ include_start_before: bool,
1329
+ ) PosByWidthResult {
1330
+ if (text.len == 0 or max_columns == 0) {
1331
+ return .{ .byte_offset = 0, .grapheme_count = 0, .columns_used = 0 };
1332
+ }
1333
+
1334
+ // ASCII-only fast path
1335
+ if (isASCIIOnly) {
1336
+ if (max_columns >= text.len) {
1337
+ return .{ .byte_offset = @intCast(text.len), .grapheme_count = @intCast(text.len), .columns_used = @intCast(text.len) };
1338
+ } else {
1339
+ return .{ .byte_offset = max_columns, .grapheme_count = max_columns, .columns_used = max_columns };
1340
+ }
1341
+ }
1342
+
1343
+ // Unicode path - process each codepoint independently
1344
+ var pos: usize = 0;
1345
+ var columns_used: u32 = 0;
1346
+ var codepoint_count: u32 = 0;
1347
+
1348
+ while (pos < text.len) {
1349
+ const b0 = text[pos];
1350
+ const curr_cp: u21 = if (b0 < 0x80) b0 else blk: {
1351
+ const dec = decodeUtf8Unchecked(text, pos);
1352
+ if (pos + dec.len > text.len) break :blk 0xFFFD;
1353
+ break :blk dec.cp;
1354
+ };
1355
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1356
+
1357
+ if (pos + cp_len > text.len) break;
1358
+
1359
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1360
+ const cp_start_col = columns_used;
1361
+ const cp_end_col = columns_used + cp_width;
1362
+
1363
+ // Apply boundary behavior
1364
+ if (include_start_before) {
1365
+ // Selection end: include codepoints that START before max_columns
1366
+ if (cp_start_col >= max_columns) {
1367
+ return .{ .byte_offset = @intCast(pos), .grapheme_count = codepoint_count, .columns_used = columns_used };
1368
+ }
1369
+ } else {
1370
+ // Selection start: only include codepoints that END before or at max_columns
1371
+ // So exclude (stop) if end > max_columns
1372
+ if (cp_end_col > max_columns) {
1373
+ return .{ .byte_offset = @intCast(pos), .grapheme_count = codepoint_count, .columns_used = columns_used };
1374
+ }
1375
+ }
1376
+
1377
+ columns_used = cp_end_col;
1378
+ codepoint_count += 1;
1379
+ pos += cp_len;
1380
+ }
1381
+
1382
+ return .{ .byte_offset = @intCast(text.len), .grapheme_count = codepoint_count, .columns_used = columns_used };
1383
+ }
1384
+
1385
+ /// Get width at byte offset - proxy function that dispatches based on width_method
1386
+ pub fn getWidthAt(text: []const u8, byte_offset: usize, tab_width: u8, width_method: WidthMethod) u32 {
1387
+ switch (width_method) {
1388
+ .unicode, .no_zwj => return getWidthAtUnicode(text, byte_offset, tab_width, width_method),
1389
+ .wcwidth => return getWidthAtWCWidth(text, byte_offset, tab_width),
1390
+ }
1391
+ }
1392
+
1393
+ /// Get width at byte offset using Unicode grapheme cluster segmentation
1394
+ fn getWidthAtUnicode(text: []const u8, byte_offset: usize, tab_width: u8, width_method: WidthMethod) u32 {
1395
+ if (byte_offset >= text.len) return 0;
1396
+
1397
+ const b0 = text[byte_offset];
1398
+
1399
+ const first_cp: u21 = if (b0 < 0x80) b0 else blk: {
1400
+ const dec = decodeUtf8Unchecked(text, byte_offset);
1401
+ if (byte_offset + dec.len > text.len) return 1;
1402
+ break :blk dec.cp;
1403
+ };
1404
+
1405
+ const first_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, byte_offset).len;
1406
+
1407
+ var break_state: uucode.grapheme.BreakState = .default;
1408
+ var prev_cp: ?u21 = first_cp;
1409
+ const first_width = charWidth(b0, first_cp, tab_width);
1410
+ var state = GraphemeWidthState.init(first_cp, first_width, width_method);
1411
+
1412
+ var pos = byte_offset + first_len;
1413
+
1414
+ while (pos < text.len) {
1415
+ const b = text[pos];
1416
+ const curr_cp: u21 = if (b < 0x80) b else decodeUtf8Unchecked(text, pos).cp;
1417
+ const cp_len: usize = if (b < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1418
+
1419
+ if (pos + cp_len > text.len) break;
1420
+
1421
+ const is_break = isGraphemeBreak(prev_cp, curr_cp, &break_state, width_method);
1422
+ if (is_break) break;
1423
+
1424
+ const cp_width = charWidth(b, curr_cp, tab_width);
1425
+ state.addCodepoint(curr_cp, cp_width);
1426
+
1427
+ prev_cp = curr_cp;
1428
+ pos += cp_len;
1429
+ }
1430
+
1431
+ return state.width;
1432
+ }
1433
+
1434
+ /// Get width at byte offset using wcwidth-style codepoint-by-codepoint processing
1435
+ /// In wcwidth mode, each codepoint is treated independently - return its width directly
1436
+ fn getWidthAtWCWidth(text: []const u8, byte_offset: usize, tab_width: u8) u32 {
1437
+ if (byte_offset >= text.len) return 0;
1438
+
1439
+ const b0 = text[byte_offset];
1440
+
1441
+ const first_cp: u21 = if (b0 < 0x80) b0 else blk: {
1442
+ const dec = decodeUtf8Unchecked(text, byte_offset);
1443
+ if (byte_offset + dec.len > text.len) return 1;
1444
+ break :blk dec.cp;
1445
+ };
1446
+
1447
+ const first_width = charWidth(b0, first_cp, tab_width);
1448
+ return first_width;
1449
+ }
1450
+
1451
+ pub const PrevGraphemeResult = struct {
1452
+ start_offset: usize,
1453
+ width: u32,
1454
+ };
1455
+
1456
+ /// Get previous grapheme start - proxy function that dispatches based on width_method
1457
+ pub fn getPrevGraphemeStart(text: []const u8, byte_offset: usize, tab_width: u8, width_method: WidthMethod) ?PrevGraphemeResult {
1458
+ switch (width_method) {
1459
+ .unicode, .no_zwj => return getPrevGraphemeStartUnicode(text, byte_offset, tab_width, width_method),
1460
+ .wcwidth => return getPrevGraphemeStartWCWidth(text, byte_offset, tab_width),
1461
+ }
1462
+ }
1463
+
1464
+ /// Get previous grapheme start using wcwidth-style codepoint-by-codepoint processing
1465
+ fn getPrevGraphemeStartWCWidth(text: []const u8, byte_offset: usize, tab_width: u8) ?PrevGraphemeResult {
1466
+ if (byte_offset == 0 or text.len == 0) return null;
1467
+ if (byte_offset > text.len) return null;
1468
+
1469
+ var pos: usize = 0;
1470
+ var last_result: ?PrevGraphemeResult = null;
1471
+
1472
+ while (pos < byte_offset) {
1473
+ const b = text[pos];
1474
+ const curr_cp: u21 = if (b < 0x80) b else blk: {
1475
+ const dec = decodeUtf8Unchecked(text, pos);
1476
+ if (pos + dec.len > text.len) break :blk 0xFFFD;
1477
+ break :blk dec.cp;
1478
+ };
1479
+ const cp_len: usize = if (b < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1480
+ const cp_width = charWidth(b, curr_cp, tab_width);
1481
+
1482
+ if (cp_width > 0) {
1483
+ last_result = .{
1484
+ .start_offset = pos,
1485
+ .width = cp_width,
1486
+ };
1487
+ }
1488
+ pos += cp_len;
1489
+ }
1490
+
1491
+ return last_result;
1492
+ }
1493
+
1494
+ /// Get previous grapheme start using Unicode grapheme cluster segmentation
1495
+ fn getPrevGraphemeStartUnicode(text: []const u8, byte_offset: usize, tab_width: u8, width_method: WidthMethod) ?PrevGraphemeResult {
1496
+ if (byte_offset == 0 or text.len == 0) return null;
1497
+ if (byte_offset > text.len) return null;
1498
+
1499
+ // For unicode/no_zwj modes, use grapheme cluster detection
1500
+ var break_state: uucode.grapheme.BreakState = .default;
1501
+ var pos: usize = 0;
1502
+ var prev_cp: ?u21 = null;
1503
+ var prev_grapheme_start: usize = 0;
1504
+ var second_to_last_grapheme_start: usize = 0;
1505
+
1506
+ while (pos < byte_offset) {
1507
+ const b = text[pos];
1508
+ const curr_cp: u21 = if (b < 0x80) b else blk: {
1509
+ const dec = decodeUtf8Unchecked(text, pos);
1510
+ if (pos + dec.len > text.len) break :blk 0xFFFD;
1511
+ break :blk dec.cp;
1512
+ };
1513
+
1514
+ const cp_len: usize = if (b < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1515
+
1516
+ if (isValidCodepoint(curr_cp)) {
1517
+ const is_break = if (prev_cp) |p| blk: {
1518
+ if (!isValidCodepoint(p)) break :blk true;
1519
+ break :blk uucode.grapheme.isBreak(p, curr_cp, &break_state);
1520
+ } else true;
1521
+
1522
+ if (is_break) {
1523
+ second_to_last_grapheme_start = prev_grapheme_start;
1524
+ prev_grapheme_start = pos;
1525
+ }
1526
+
1527
+ prev_cp = curr_cp;
1528
+ }
1529
+
1530
+ pos += cp_len;
1531
+ }
1532
+
1533
+ if (prev_grapheme_start == 0 and byte_offset == 0) {
1534
+ return null;
1535
+ }
1536
+
1537
+ const start_offset = if (prev_grapheme_start < byte_offset) prev_grapheme_start else second_to_last_grapheme_start;
1538
+ const width = getWidthAt(text, start_offset, tab_width, width_method);
1539
+
1540
+ return .{
1541
+ .start_offset = start_offset,
1542
+ .width = width,
1543
+ };
1544
+ }
1545
+
1546
+ /// Calculate the display width of text - proxy function that dispatches based on width_method
1547
+ pub fn calculateTextWidth(text: []const u8, tab_width: u8, isASCIIOnly: bool, width_method: WidthMethod) u32 {
1548
+ switch (width_method) {
1549
+ .unicode, .no_zwj => return calculateTextWidthUnicode(text, tab_width, isASCIIOnly, width_method),
1550
+ .wcwidth => return calculateTextWidthWCWidth(text, tab_width, isASCIIOnly),
1551
+ }
1552
+ }
1553
+
1554
+ /// Calculate text width using Unicode grapheme cluster segmentation
1555
+ fn calculateTextWidthUnicode(text: []const u8, tab_width: u8, isASCIIOnly: bool, width_method: WidthMethod) u32 {
1556
+ if (text.len == 0) return 0;
1557
+
1558
+ // ASCII-only fast path
1559
+ if (isASCIIOnly) {
1560
+ return @intCast(text.len);
1561
+ }
1562
+
1563
+ // General case with Unicode support and grapheme cluster handling
1564
+ var total_width: u32 = 0;
1565
+ var pos: usize = 0;
1566
+ var prev_cp: ?u21 = null;
1567
+ var break_state: uucode.grapheme.BreakState = .default;
1568
+ var state: GraphemeWidthState = undefined;
1569
+
1570
+ while (pos < text.len) {
1571
+ const b0 = text[pos];
1572
+ const curr_cp: u21 = if (b0 < 0x80) b0 else blk: {
1573
+ const dec = decodeUtf8Unchecked(text, pos);
1574
+ if (pos + dec.len > text.len) break :blk 0xFFFD;
1575
+ break :blk dec.cp;
1576
+ };
1577
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1578
+ const is_break = isGraphemeBreak(prev_cp, curr_cp, &break_state, width_method);
1579
+
1580
+ if (is_break) {
1581
+ if (prev_cp != null) {
1582
+ total_width += state.width;
1583
+ }
1584
+
1585
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1586
+ state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1587
+ } else {
1588
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1589
+ state.addCodepoint(curr_cp, cp_width);
1590
+ }
1591
+
1592
+ prev_cp = curr_cp;
1593
+ pos += cp_len;
1594
+ }
1595
+
1596
+ if (prev_cp != null) {
1597
+ total_width += state.width;
1598
+ }
1599
+
1600
+ return total_width;
1601
+ }
1602
+
1603
+ /// Calculate text width using wcwidth-style codepoint-by-codepoint processing
1604
+ fn calculateTextWidthWCWidth(text: []const u8, tab_width: u8, isASCIIOnly: bool) u32 {
1605
+ if (text.len == 0) return 0;
1606
+
1607
+ // ASCII-only fast path
1608
+ if (isASCIIOnly) {
1609
+ return @intCast(text.len);
1610
+ }
1611
+
1612
+ // Unicode path - sum width of all codepoints
1613
+ var total_width: u32 = 0;
1614
+ var pos: usize = 0;
1615
+
1616
+ while (pos < text.len) {
1617
+ const b0 = text[pos];
1618
+ const curr_cp: u21 = if (b0 < 0x80) b0 else blk: {
1619
+ const dec = decodeUtf8Unchecked(text, pos);
1620
+ if (pos + dec.len > text.len) break :blk 0xFFFD;
1621
+ break :blk dec.cp;
1622
+ };
1623
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1624
+
1625
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1626
+ total_width += cp_width;
1627
+
1628
+ pos += cp_len;
1629
+ }
1630
+
1631
+ return total_width;
1632
+ }
1633
+
1634
+ /// Grapheme cluster information for caching
1635
+ pub const GraphemeInfo = struct {
1636
+ byte_offset: u32,
1637
+ byte_len: u8,
1638
+ width: u8,
1639
+ col_offset: u32,
1640
+ };
1641
+
1642
+ pub const GraphemeInfoResult = struct {
1643
+ graphemes: std.ArrayList(GraphemeInfo),
1644
+
1645
+ pub fn init(allocator: std.mem.Allocator) GraphemeInfoResult {
1646
+ return .{
1647
+ .graphemes = std.ArrayList(GraphemeInfo).init(allocator),
1648
+ };
1649
+ }
1650
+
1651
+ pub fn deinit(self: *GraphemeInfoResult) void {
1652
+ self.graphemes.deinit();
1653
+ self.* = undefined;
1654
+ }
1655
+
1656
+ pub fn reset(self: *GraphemeInfoResult) void {
1657
+ self.graphemes.clearRetainingCapacity();
1658
+ }
1659
+ };
1660
+
1661
+ /// Find all grapheme clusters in text and return info for multi-byte graphemes and tabs
1662
+ /// This is a proxy function that dispatches to the appropriate implementation based on width_method
1663
+ pub fn findGraphemeInfo(
1664
+ allocator: std.mem.Allocator,
1665
+ text: []const u8,
1666
+ tab_width: u8,
1667
+ isASCIIOnly: bool,
1668
+ width_method: WidthMethod,
1669
+ result: *std.ArrayListUnmanaged(GraphemeInfo),
1670
+ ) !void {
1671
+ switch (width_method) {
1672
+ .unicode, .no_zwj => try findGraphemeInfoUnicode(allocator, text, tab_width, isASCIIOnly, width_method, result),
1673
+ .wcwidth => try findGraphemeInfoWCWidth(allocator, text, tab_width, isASCIIOnly, result),
1674
+ }
1675
+ }
1676
+
1677
+ /// Find all grapheme clusters using Unicode grapheme cluster segmentation
1678
+ /// This version treats grapheme clusters as single units for width calculation
1679
+ fn findGraphemeInfoUnicode(
1680
+ allocator: std.mem.Allocator,
1681
+ text: []const u8,
1682
+ tab_width: u8,
1683
+ isASCIIOnly: bool,
1684
+ width_method: WidthMethod,
1685
+ result: *std.ArrayListUnmanaged(GraphemeInfo),
1686
+ ) !void {
1687
+ // In wcwidth mode, always process to capture combining marks on ASCII
1688
+ if (isASCIIOnly and width_method != .wcwidth) {
1689
+ return;
1690
+ }
1691
+
1692
+ if (text.len == 0) {
1693
+ return;
1694
+ }
1695
+
1696
+ const vector_len = 16;
1697
+ var pos: usize = 0;
1698
+ var col: u32 = 0;
1699
+ var prev_cp: ?u21 = null;
1700
+ var break_state: uucode.grapheme.BreakState = .default;
1701
+
1702
+ // Track current grapheme cluster
1703
+ var cluster_start: usize = 0;
1704
+ var cluster_start_col: u32 = 0;
1705
+ var cluster_width_state: GraphemeWidthState = undefined;
1706
+ var cluster_is_multibyte: bool = false;
1707
+ var cluster_is_tab: bool = false;
1708
+
1709
+ while (pos + vector_len <= text.len) {
1710
+ const chunk: @Vector(vector_len, u8) = text[pos..][0..vector_len].*;
1711
+ const ascii_threshold: @Vector(vector_len, u8) = @splat(0x80);
1712
+ const is_non_ascii = chunk >= ascii_threshold;
1713
+
1714
+ // Fast path: all ASCII
1715
+ if (!@reduce(.Or, is_non_ascii)) {
1716
+ var i: usize = 0;
1717
+ while (i < vector_len) : (i += 1) {
1718
+ const b = text[pos + i];
1719
+ const curr_cp: u21 = b;
1720
+ const is_break = isGraphemeBreak(prev_cp, curr_cp, &break_state, width_method);
1721
+
1722
+ if (is_break) {
1723
+ if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) {
1724
+ if (cluster_width_state.width > 0 or width_method == .wcwidth) {
1725
+ const cluster_byte_len = (pos + i) - cluster_start;
1726
+ try result.append(allocator, .{
1727
+ .byte_offset = @intCast(cluster_start),
1728
+ .byte_len = @intCast(cluster_byte_len),
1729
+ .width = @intCast(cluster_width_state.width),
1730
+ .col_offset = cluster_start_col,
1731
+ });
1732
+ }
1733
+ col += cluster_width_state.width;
1734
+ } else if (prev_cp != null) {
1735
+ col += cluster_width_state.width;
1736
+ }
1737
+
1738
+ cluster_start = pos + i;
1739
+ cluster_start_col = col;
1740
+ cluster_is_tab = (b == '\t');
1741
+ cluster_is_multibyte = false;
1742
+
1743
+ const cp_width = asciiCharWidth(b, tab_width);
1744
+ cluster_width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1745
+ } else {
1746
+ // Continuing cluster (shouldn't happen for ASCII, but handle it)
1747
+ const cp_width = asciiCharWidth(b, tab_width);
1748
+ cluster_width_state.addCodepoint(curr_cp, cp_width);
1749
+ }
1750
+
1751
+ prev_cp = curr_cp;
1752
+ }
1753
+ pos += vector_len;
1754
+ continue;
1755
+ }
1756
+
1757
+ // Slow path: mixed ASCII/non-ASCII
1758
+ var i: usize = 0;
1759
+ while (i < vector_len and pos + i < text.len) {
1760
+ const b0 = text[pos + i];
1761
+ const curr_cp: u21 = if (b0 < 0x80) b0 else decodeUtf8Unchecked(text, pos + i).cp;
1762
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos + i).len;
1763
+
1764
+ if (pos + i + cp_len > text.len) break;
1765
+
1766
+ const is_break = isGraphemeBreak(prev_cp, curr_cp, &break_state, width_method);
1767
+
1768
+ if (is_break) {
1769
+ if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) {
1770
+ if (cluster_width_state.width > 0 or width_method == .wcwidth) {
1771
+ const cluster_byte_len = (pos + i) - cluster_start;
1772
+ try result.append(allocator, .{
1773
+ .byte_offset = @intCast(cluster_start),
1774
+ .byte_len = @intCast(cluster_byte_len),
1775
+ .width = @intCast(cluster_width_state.width),
1776
+ .col_offset = cluster_start_col,
1777
+ });
1778
+ }
1779
+ col += cluster_width_state.width;
1780
+ } else if (prev_cp != null) {
1781
+ col += cluster_width_state.width;
1782
+ }
1783
+
1784
+ cluster_start = pos + i;
1785
+ cluster_start_col = col;
1786
+ cluster_is_tab = (b0 == '\t');
1787
+ cluster_is_multibyte = (cp_len != 1);
1788
+
1789
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1790
+ cluster_width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1791
+ } else {
1792
+ cluster_is_multibyte = cluster_is_multibyte or (cp_len != 1);
1793
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1794
+ cluster_width_state.addCodepoint(curr_cp, cp_width);
1795
+ }
1796
+
1797
+ prev_cp = curr_cp;
1798
+ i += cp_len;
1799
+ }
1800
+ pos += i;
1801
+ }
1802
+
1803
+ // Tail processing
1804
+ while (pos < text.len) {
1805
+ const b0 = text[pos];
1806
+ const curr_cp: u21 = if (b0 < 0x80) b0 else decodeUtf8Unchecked(text, pos).cp;
1807
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1808
+
1809
+ if (pos + cp_len > text.len) break;
1810
+
1811
+ const is_break = isGraphemeBreak(prev_cp, curr_cp, &break_state, width_method);
1812
+
1813
+ if (is_break) {
1814
+ if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) {
1815
+ if (cluster_width_state.width > 0 or width_method == .wcwidth) {
1816
+ const cluster_byte_len = pos - cluster_start;
1817
+ try result.append(allocator, .{
1818
+ .byte_offset = @intCast(cluster_start),
1819
+ .byte_len = @intCast(cluster_byte_len),
1820
+ .width = @intCast(cluster_width_state.width),
1821
+ .col_offset = cluster_start_col,
1822
+ });
1823
+ }
1824
+ col += cluster_width_state.width;
1825
+ } else if (prev_cp != null) {
1826
+ col += cluster_width_state.width;
1827
+ }
1828
+
1829
+ cluster_start = pos;
1830
+ cluster_start_col = col;
1831
+ cluster_is_tab = (b0 == '\t');
1832
+ cluster_is_multibyte = (cp_len != 1);
1833
+
1834
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1835
+ cluster_width_state = GraphemeWidthState.init(curr_cp, cp_width, width_method);
1836
+ } else {
1837
+ cluster_is_multibyte = cluster_is_multibyte or (cp_len != 1);
1838
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1839
+ cluster_width_state.addCodepoint(curr_cp, cp_width);
1840
+ }
1841
+
1842
+ prev_cp = curr_cp;
1843
+ pos += cp_len;
1844
+ }
1845
+
1846
+ if (prev_cp != null and (cluster_is_multibyte or cluster_is_tab)) {
1847
+ if (cluster_width_state.width > 0 or width_method == .wcwidth) {
1848
+ const cluster_byte_len = text.len - cluster_start;
1849
+ try result.append(allocator, .{
1850
+ .byte_offset = @intCast(cluster_start),
1851
+ .byte_len = @intCast(cluster_byte_len),
1852
+ .width = @intCast(cluster_width_state.width),
1853
+ .col_offset = cluster_start_col,
1854
+ });
1855
+ }
1856
+ }
1857
+ }
1858
+
1859
+ /// Find all grapheme clusters using wcwidth-style codepoint-by-codepoint processing
1860
+ /// This version treats each codepoint as a separate character (tmux/wcwidth behavior)
1861
+ fn findGraphemeInfoWCWidth(
1862
+ allocator: std.mem.Allocator,
1863
+ text: []const u8,
1864
+ tab_width: u8,
1865
+ isASCIIOnly: bool,
1866
+ result: *std.ArrayListUnmanaged(GraphemeInfo),
1867
+ ) !void {
1868
+ // wcwidth mode should still produce the same grapheme cluster boundaries as Unicode
1869
+ // (so ZWJ sequences and combining marks stay together), but the width of each cluster
1870
+ // is calculated using wcwidth (sum of codepoint widths). This keeps rendering coherent
1871
+ // while preserving tmux-style widths.
1872
+ if (isASCIIOnly) {
1873
+ return;
1874
+ }
1875
+
1876
+ if (text.len == 0) {
1877
+ return;
1878
+ }
1879
+
1880
+ var pos: usize = 0;
1881
+ var col: u32 = 0;
1882
+ var prev_cp: ?u21 = null;
1883
+ var break_state: uucode.grapheme.BreakState = .default;
1884
+
1885
+ // Track current cluster
1886
+ var cluster_start: usize = 0;
1887
+ var cluster_start_col: u32 = 0;
1888
+ var cluster_width_state: GraphemeWidthState = undefined;
1889
+ var cluster_is_multibyte: bool = false;
1890
+ var cluster_is_tab: bool = false;
1891
+ var cluster_started = false;
1892
+
1893
+ while (pos < text.len) {
1894
+ const b0 = text[pos];
1895
+ const curr_cp: u21 = if (b0 < 0x80) b0 else blk: {
1896
+ const dec = decodeUtf8Unchecked(text, pos);
1897
+ if (pos + dec.len > text.len) break :blk 0xFFFD;
1898
+ break :blk dec.cp;
1899
+ };
1900
+ const cp_len: usize = if (b0 < 0x80) 1 else decodeUtf8Unchecked(text, pos).len;
1901
+
1902
+ if (pos + cp_len > text.len) break;
1903
+
1904
+ // Use wcwidth break detection (each codepoint is separate, tmux-style)
1905
+ const is_break = isGraphemeBreak(prev_cp, curr_cp, &break_state, .wcwidth);
1906
+
1907
+ if (is_break) {
1908
+ if (cluster_started and (cluster_is_multibyte or cluster_is_tab)) {
1909
+ try result.append(allocator, .{
1910
+ .byte_offset = @intCast(cluster_start),
1911
+ .byte_len = @intCast(pos - cluster_start),
1912
+ .width = @intCast(cluster_width_state.width),
1913
+ .col_offset = cluster_start_col,
1914
+ });
1915
+ col += cluster_width_state.width;
1916
+ } else if (cluster_started) {
1917
+ // Still need to advance col by cluster width even if not emitted
1918
+ col += cluster_width_state.width;
1919
+ }
1920
+
1921
+ // Start a new cluster
1922
+ cluster_start = pos;
1923
+ cluster_start_col = col;
1924
+ cluster_is_tab = (b0 == '\t');
1925
+ cluster_is_multibyte = (cp_len != 1);
1926
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1927
+ cluster_width_state = GraphemeWidthState.init(curr_cp, cp_width, .wcwidth);
1928
+ cluster_started = true;
1929
+ } else {
1930
+ // Continuing cluster
1931
+ cluster_is_multibyte = cluster_is_multibyte or (cp_len != 1);
1932
+ const cp_width = charWidth(b0, curr_cp, tab_width);
1933
+ cluster_width_state.addCodepoint(curr_cp, cp_width);
1934
+ }
1935
+
1936
+ prev_cp = curr_cp;
1937
+ pos += cp_len;
1938
+ }
1939
+
1940
+ // Commit final cluster
1941
+ if (cluster_started) {
1942
+ if (cluster_is_multibyte or cluster_is_tab) {
1943
+ try result.append(allocator, .{
1944
+ .byte_offset = @intCast(cluster_start),
1945
+ .byte_len = @intCast(text.len - cluster_start),
1946
+ .width = @intCast(cluster_width_state.width),
1947
+ .col_offset = cluster_start_col,
1948
+ });
1949
+ col += cluster_width_state.width;
1950
+ } else {
1951
+ col += cluster_width_state.width;
1952
+ }
1953
+ }
1954
+ }