@effect-tui/react 0.15.2 → 2.0.0

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 (313) hide show
  1. package/README.md +11 -2
  2. package/dist/src/codeblock.d.ts +1 -1
  3. package/dist/src/codeblock.d.ts.map +1 -1
  4. package/dist/src/codeblock.js +2 -2
  5. package/dist/src/codeblock.js.map +1 -1
  6. package/dist/src/components/ListView.d.ts +4 -4
  7. package/dist/src/components/ListView.d.ts.map +1 -1
  8. package/dist/src/components/ListView.js +16 -17
  9. package/dist/src/components/ListView.js.map +1 -1
  10. package/dist/src/components/Markdown.js +3 -3
  11. package/dist/src/components/Markdown.js.map +1 -1
  12. package/dist/src/components/MultilineTextInput.d.ts.map +1 -1
  13. package/dist/src/components/MultilineTextInput.js +133 -305
  14. package/dist/src/components/MultilineTextInput.js.map +1 -1
  15. package/dist/src/components/TextInput.d.ts.map +1 -1
  16. package/dist/src/components/TextInput.js +51 -98
  17. package/dist/src/components/TextInput.js.map +1 -1
  18. package/dist/src/components/text-editing.d.ts +61 -0
  19. package/dist/src/components/text-editing.d.ts.map +1 -1
  20. package/dist/src/components/text-editing.js +131 -0
  21. package/dist/src/components/text-editing.js.map +1 -1
  22. package/dist/src/console/ConsolePopover.d.ts +7 -1
  23. package/dist/src/console/ConsolePopover.d.ts.map +1 -1
  24. package/dist/src/console/ConsolePopover.js +55 -74
  25. package/dist/src/console/ConsolePopover.js.map +1 -1
  26. package/dist/src/debug/DebugOverlay.d.ts.map +1 -1
  27. package/dist/src/debug/DebugOverlay.js +3 -57
  28. package/dist/src/debug/DebugOverlay.js.map +1 -1
  29. package/dist/src/debug/DiagnosticsPanel.js +1 -1
  30. package/dist/src/debug/DiagnosticsPanel.js.map +1 -1
  31. package/dist/src/dev.d.ts +5 -117
  32. package/dist/src/dev.d.ts.map +1 -1
  33. package/dist/src/dev.js +3 -333
  34. package/dist/src/dev.js.map +1 -1
  35. package/dist/src/hooks/use-scroll.d.ts +31 -35
  36. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  37. package/dist/src/hooks/use-scroll.js +51 -90
  38. package/dist/src/hooks/use-scroll.js.map +1 -1
  39. package/dist/src/hosts/base.d.ts +13 -2
  40. package/dist/src/hosts/base.d.ts.map +1 -1
  41. package/dist/src/hosts/base.js +74 -2
  42. package/dist/src/hosts/base.js.map +1 -1
  43. package/dist/src/hosts/box.d.ts +2 -2
  44. package/dist/src/hosts/box.d.ts.map +1 -1
  45. package/dist/src/hosts/box.js +29 -2
  46. package/dist/src/hosts/box.js.map +1 -1
  47. package/dist/src/hosts/canvas.d.ts +24 -4
  48. package/dist/src/hosts/canvas.d.ts.map +1 -1
  49. package/dist/src/hosts/canvas.js +107 -41
  50. package/dist/src/hosts/canvas.js.map +1 -1
  51. package/dist/src/hosts/codeblock.d.ts +10 -12
  52. package/dist/src/hosts/codeblock.d.ts.map +1 -1
  53. package/dist/src/hosts/codeblock.js +38 -35
  54. package/dist/src/hosts/codeblock.js.map +1 -1
  55. package/dist/src/hosts/flex-container.d.ts +3 -3
  56. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  57. package/dist/src/hosts/flex-container.js +20 -5
  58. package/dist/src/hosts/flex-container.js.map +1 -1
  59. package/dist/src/hosts/index.d.ts +3 -2
  60. package/dist/src/hosts/index.d.ts.map +1 -1
  61. package/dist/src/hosts/index.js +2 -1
  62. package/dist/src/hosts/index.js.map +1 -1
  63. package/dist/src/hosts/layout-helpers.d.ts +10 -0
  64. package/dist/src/hosts/layout-helpers.d.ts.map +1 -0
  65. package/dist/src/hosts/layout-helpers.js +10 -0
  66. package/dist/src/hosts/layout-helpers.js.map +1 -0
  67. package/dist/src/hosts/leaf.d.ts +14 -0
  68. package/dist/src/hosts/leaf.d.ts.map +1 -0
  69. package/dist/src/hosts/leaf.js +31 -0
  70. package/dist/src/hosts/leaf.js.map +1 -0
  71. package/dist/src/hosts/overlay-item.d.ts +2 -2
  72. package/dist/src/hosts/overlay-item.d.ts.map +1 -1
  73. package/dist/src/hosts/overlay-item.js +7 -2
  74. package/dist/src/hosts/overlay-item.js.map +1 -1
  75. package/dist/src/hosts/overlay.d.ts +2 -2
  76. package/dist/src/hosts/overlay.d.ts.map +1 -1
  77. package/dist/src/hosts/overlay.js +6 -9
  78. package/dist/src/hosts/overlay.js.map +1 -1
  79. package/dist/src/hosts/scroll.d.ts +54 -26
  80. package/dist/src/hosts/scroll.d.ts.map +1 -1
  81. package/dist/src/hosts/scroll.js +185 -87
  82. package/dist/src/hosts/scroll.js.map +1 -1
  83. package/dist/src/hosts/single-child.d.ts.map +1 -1
  84. package/dist/src/hosts/single-child.js +2 -0
  85. package/dist/src/hosts/single-child.js.map +1 -1
  86. package/dist/src/hosts/spacer.d.ts +3 -3
  87. package/dist/src/hosts/spacer.d.ts.map +1 -1
  88. package/dist/src/hosts/spacer.js +8 -3
  89. package/dist/src/hosts/spacer.js.map +1 -1
  90. package/dist/src/hosts/text.d.ts +22 -18
  91. package/dist/src/hosts/text.d.ts.map +1 -1
  92. package/dist/src/hosts/text.js +108 -131
  93. package/dist/src/hosts/text.js.map +1 -1
  94. package/dist/src/hosts/vstack.js +1 -1
  95. package/dist/src/hosts/vstack.js.map +1 -1
  96. package/dist/src/hosts/zstack.d.ts +3 -3
  97. package/dist/src/hosts/zstack.d.ts.map +1 -1
  98. package/dist/src/hosts/zstack.js +13 -8
  99. package/dist/src/hosts/zstack.js.map +1 -1
  100. package/dist/src/index.d.ts +2 -2
  101. package/dist/src/index.d.ts.map +1 -1
  102. package/dist/src/internal/dev/hmr.d.ts +20 -0
  103. package/dist/src/internal/dev/hmr.d.ts.map +1 -0
  104. package/dist/src/internal/dev/hmr.js +93 -0
  105. package/dist/src/internal/dev/hmr.js.map +1 -0
  106. package/dist/src/internal/dev/runtime.d.ts +24 -0
  107. package/dist/src/internal/dev/runtime.d.ts.map +1 -0
  108. package/dist/src/internal/dev/runtime.js +135 -0
  109. package/dist/src/internal/dev/runtime.js.map +1 -0
  110. package/dist/src/internal/dev/ui.d.ts +13 -0
  111. package/dist/src/internal/dev/ui.d.ts.map +1 -0
  112. package/dist/src/internal/dev/ui.js +51 -0
  113. package/dist/src/internal/dev/ui.js.map +1 -0
  114. package/dist/src/internal/renderer/context.d.ts +9 -0
  115. package/dist/src/internal/renderer/context.d.ts.map +1 -0
  116. package/dist/src/internal/renderer/context.js +22 -0
  117. package/dist/src/internal/renderer/context.js.map +1 -0
  118. package/dist/src/internal/renderer/core/FrameBuilder.d.ts +18 -0
  119. package/dist/src/internal/renderer/core/FrameBuilder.d.ts.map +1 -0
  120. package/dist/src/internal/renderer/core/FrameBuilder.js +40 -0
  121. package/dist/src/internal/renderer/core/FrameBuilder.js.map +1 -0
  122. package/dist/src/internal/renderer/core/RendererState.d.ts +41 -0
  123. package/dist/src/internal/renderer/core/RendererState.d.ts.map +1 -0
  124. package/dist/src/internal/renderer/core/RendererState.js +70 -0
  125. package/dist/src/internal/renderer/core/RendererState.js.map +1 -0
  126. package/dist/src/internal/renderer/core/index.d.ts +3 -0
  127. package/dist/src/internal/renderer/core/index.d.ts.map +1 -0
  128. package/dist/src/internal/renderer/core/index.js +3 -0
  129. package/dist/src/internal/renderer/core/index.js.map +1 -0
  130. package/dist/src/internal/renderer/index.d.ts +40 -0
  131. package/dist/src/internal/renderer/index.d.ts.map +1 -0
  132. package/dist/src/internal/renderer/index.js +543 -0
  133. package/dist/src/internal/renderer/index.js.map +1 -0
  134. package/dist/src/internal/renderer/input/InputProcessor.d.ts +30 -0
  135. package/dist/src/internal/renderer/input/InputProcessor.d.ts.map +1 -0
  136. package/dist/src/internal/renderer/input/InputProcessor.js +122 -0
  137. package/dist/src/internal/renderer/input/InputProcessor.js.map +1 -0
  138. package/dist/src/internal/renderer/input/index.d.ts +2 -0
  139. package/dist/src/internal/renderer/input/index.d.ts.map +1 -0
  140. package/dist/src/internal/renderer/input/index.js +2 -0
  141. package/dist/src/internal/renderer/input/index.js.map +1 -0
  142. package/dist/src/internal/renderer/lifecycle/EventBus.d.ts +42 -0
  143. package/dist/src/internal/renderer/lifecycle/EventBus.d.ts.map +1 -0
  144. package/dist/src/internal/renderer/lifecycle/EventBus.js +97 -0
  145. package/dist/src/internal/renderer/lifecycle/EventBus.js.map +1 -0
  146. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.d.ts +13 -0
  147. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.d.ts.map +1 -0
  148. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.js +111 -0
  149. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.js.map +1 -0
  150. package/dist/src/internal/renderer/lifecycle/RenderCache.d.ts +3 -0
  151. package/dist/src/internal/renderer/lifecycle/RenderCache.d.ts.map +1 -0
  152. package/dist/src/internal/renderer/lifecycle/RenderCache.js +9 -0
  153. package/dist/src/internal/renderer/lifecycle/RenderCache.js.map +1 -0
  154. package/dist/src/internal/renderer/lifecycle/index.d.ts +4 -0
  155. package/dist/src/internal/renderer/lifecycle/index.d.ts.map +1 -0
  156. package/dist/src/internal/renderer/lifecycle/index.js +4 -0
  157. package/dist/src/internal/renderer/lifecycle/index.js.map +1 -0
  158. package/dist/src/internal/renderer/modes/FullscreenRenderer.d.ts +12 -0
  159. package/dist/src/internal/renderer/modes/FullscreenRenderer.d.ts.map +1 -0
  160. package/dist/src/internal/renderer/modes/FullscreenRenderer.js +54 -0
  161. package/dist/src/internal/renderer/modes/FullscreenRenderer.js.map +1 -0
  162. package/dist/src/internal/renderer/modes/InlineRenderer.d.ts +25 -0
  163. package/dist/src/internal/renderer/modes/InlineRenderer.d.ts.map +1 -0
  164. package/dist/src/internal/renderer/modes/InlineRenderer.js +166 -0
  165. package/dist/src/internal/renderer/modes/InlineRenderer.js.map +1 -0
  166. package/dist/src/internal/renderer/modes/RendererMode.d.ts +42 -0
  167. package/dist/src/internal/renderer/modes/RendererMode.d.ts.map +1 -0
  168. package/dist/src/internal/renderer/modes/RendererMode.js +2 -0
  169. package/dist/src/internal/renderer/modes/RendererMode.js.map +1 -0
  170. package/dist/src/internal/renderer/modes/StaticContentRenderer.d.ts +25 -0
  171. package/dist/src/internal/renderer/modes/StaticContentRenderer.d.ts.map +1 -0
  172. package/dist/src/internal/renderer/modes/StaticContentRenderer.js +49 -0
  173. package/dist/src/internal/renderer/modes/StaticContentRenderer.js.map +1 -0
  174. package/dist/src/internal/renderer/modes/index.d.ts +5 -0
  175. package/dist/src/internal/renderer/modes/index.d.ts.map +1 -0
  176. package/dist/src/internal/renderer/modes/index.js +4 -0
  177. package/dist/src/internal/renderer/modes/index.js.map +1 -0
  178. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.d.ts +13 -0
  179. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.d.ts.map +1 -0
  180. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.js +75 -0
  181. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.js.map +1 -0
  182. package/dist/src/internal/renderer/terminal/TerminalSetup.d.ts +29 -0
  183. package/dist/src/internal/renderer/terminal/TerminalSetup.d.ts.map +1 -0
  184. package/dist/src/internal/renderer/terminal/TerminalSetup.js +82 -0
  185. package/dist/src/internal/renderer/terminal/TerminalSetup.js.map +1 -0
  186. package/dist/src/internal/renderer/terminal/index.d.ts +3 -0
  187. package/dist/src/internal/renderer/terminal/index.d.ts.map +1 -0
  188. package/dist/src/internal/renderer/terminal/index.js +3 -0
  189. package/dist/src/internal/renderer/terminal/index.js.map +1 -0
  190. package/dist/src/internal/renderer/types.d.ts +122 -0
  191. package/dist/src/internal/renderer/types.d.ts.map +1 -0
  192. package/dist/src/internal/renderer/types.js +2 -0
  193. package/dist/src/internal/renderer/types.js.map +1 -0
  194. package/dist/src/motion/hooks.d.ts +1 -1
  195. package/dist/src/motion/hooks.js +1 -1
  196. package/dist/src/reconciler/host-config.js +2 -2
  197. package/dist/src/reconciler/host-config.js.map +1 -1
  198. package/dist/src/reconciler/types.d.ts +5 -1
  199. package/dist/src/reconciler/types.d.ts.map +1 -1
  200. package/dist/src/renderer-context.d.ts +1 -8
  201. package/dist/src/renderer-context.d.ts.map +1 -1
  202. package/dist/src/renderer-context.js +1 -21
  203. package/dist/src/renderer-context.js.map +1 -1
  204. package/dist/src/renderer-types.d.ts +1 -115
  205. package/dist/src/renderer-types.d.ts.map +1 -1
  206. package/dist/src/renderer.d.ts +1 -31
  207. package/dist/src/renderer.d.ts.map +1 -1
  208. package/dist/src/renderer.js +1 -495
  209. package/dist/src/renderer.js.map +1 -1
  210. package/dist/src/test/render-tui.d.ts +3 -3
  211. package/dist/src/test/render-tui.d.ts.map +1 -1
  212. package/dist/src/test/render-tui.js +16 -9
  213. package/dist/src/test/render-tui.js.map +1 -1
  214. package/dist/src/utils/alignment.d.ts +1 -1
  215. package/dist/src/utils/alignment.d.ts.map +1 -1
  216. package/dist/src/utils/alignment.js +0 -2
  217. package/dist/src/utils/alignment.js.map +1 -1
  218. package/dist/src/utils/border.d.ts +1 -1
  219. package/dist/src/utils/border.d.ts.map +1 -1
  220. package/dist/src/utils/border.js +2 -0
  221. package/dist/src/utils/border.js.map +1 -1
  222. package/dist/src/utils/console-helpers.d.ts +19 -0
  223. package/dist/src/utils/console-helpers.d.ts.map +1 -0
  224. package/dist/src/utils/console-helpers.js +61 -0
  225. package/dist/src/utils/console-helpers.js.map +1 -0
  226. package/dist/src/utils/index.d.ts +2 -1
  227. package/dist/src/utils/index.d.ts.map +1 -1
  228. package/dist/src/utils/index.js +2 -1
  229. package/dist/src/utils/index.js.map +1 -1
  230. package/dist/src/utils/styles.d.ts +8 -1
  231. package/dist/src/utils/styles.d.ts.map +1 -1
  232. package/dist/src/utils/styles.js +10 -8
  233. package/dist/src/utils/styles.js.map +1 -1
  234. package/dist/src/utils/text-layout.d.ts +22 -0
  235. package/dist/src/utils/text-layout.d.ts.map +1 -0
  236. package/dist/src/utils/text-layout.js +37 -0
  237. package/dist/src/utils/text-layout.js.map +1 -0
  238. package/dist/src/utils/text-wrap.d.ts +31 -1
  239. package/dist/src/utils/text-wrap.d.ts.map +1 -1
  240. package/dist/src/utils/text-wrap.js +205 -48
  241. package/dist/src/utils/text-wrap.js.map +1 -1
  242. package/dist/src/visualize/index.js +1 -1
  243. package/dist/src/visualize/index.js.map +1 -1
  244. package/dist/tsconfig.tsbuildinfo +1 -1
  245. package/package.json +2 -2
  246. package/src/codeblock.tsx +2 -2
  247. package/src/components/ListView.tsx +21 -23
  248. package/src/components/Markdown.tsx +3 -3
  249. package/src/components/MultilineTextInput.tsx +138 -344
  250. package/src/components/TextInput.tsx +54 -99
  251. package/src/components/text-editing.ts +180 -0
  252. package/src/console/ConsolePopover.tsx +124 -107
  253. package/src/debug/DebugOverlay.ts +15 -74
  254. package/src/debug/DiagnosticsPanel.tsx +1 -1
  255. package/src/dev.tsx +5 -458
  256. package/src/hooks/use-scroll.ts +85 -145
  257. package/src/hosts/base.ts +86 -3
  258. package/src/hosts/box.ts +37 -2
  259. package/src/hosts/canvas.ts +128 -42
  260. package/src/hosts/codeblock.ts +48 -35
  261. package/src/hosts/flex-container.ts +25 -6
  262. package/src/hosts/index.ts +11 -2
  263. package/src/hosts/layout-helpers.ts +20 -0
  264. package/src/hosts/leaf.ts +36 -0
  265. package/src/hosts/overlay-item.ts +8 -2
  266. package/src/hosts/overlay.ts +13 -11
  267. package/src/hosts/scroll.ts +228 -106
  268. package/src/hosts/single-child.ts +2 -0
  269. package/src/hosts/spacer.ts +8 -3
  270. package/src/hosts/text.ts +126 -132
  271. package/src/hosts/vstack.ts +1 -1
  272. package/src/hosts/zstack.ts +14 -9
  273. package/src/index.ts +2 -2
  274. package/src/internal/dev/hmr.ts +101 -0
  275. package/src/internal/dev/runtime.ts +170 -0
  276. package/src/internal/dev/ui.tsx +87 -0
  277. package/src/internal/renderer/context.ts +27 -0
  278. package/src/{renderer → internal/renderer}/core/FrameBuilder.ts +2 -2
  279. package/src/internal/renderer/index.ts +689 -0
  280. package/src/{renderer → internal/renderer}/input/InputProcessor.ts +10 -1
  281. package/src/{renderer → internal/renderer}/lifecycle/EventBus.ts +9 -1
  282. package/src/internal/renderer/lifecycle/ProcessLifecycle.ts +125 -0
  283. package/src/internal/renderer/lifecycle/index.ts +3 -0
  284. package/src/{renderer → internal/renderer}/modes/InlineRenderer.ts +5 -2
  285. package/src/{renderer → internal/renderer}/modes/RendererMode.ts +1 -1
  286. package/src/{renderer → internal/renderer}/modes/StaticContentRenderer.ts +5 -2
  287. package/src/internal/renderer/terminal/KeyboardCapabilityProbe.ts +91 -0
  288. package/src/{renderer/lifecycle → internal/renderer/terminal}/TerminalSetup.ts +4 -22
  289. package/src/internal/renderer/terminal/index.ts +2 -0
  290. package/src/internal/renderer/types.ts +129 -0
  291. package/src/motion/hooks.ts +1 -1
  292. package/src/reconciler/host-config.ts +2 -2
  293. package/src/reconciler/types.ts +7 -1
  294. package/src/renderer-context.ts +1 -27
  295. package/src/renderer-types.ts +10 -123
  296. package/src/renderer.ts +1 -619
  297. package/src/test/render-tui.ts +16 -10
  298. package/src/utils/alignment.ts +1 -3
  299. package/src/utils/border.ts +11 -1
  300. package/src/utils/console-helpers.ts +86 -0
  301. package/src/utils/index.ts +15 -1
  302. package/src/utils/styles.ts +16 -4
  303. package/src/utils/text-layout.ts +65 -0
  304. package/src/utils/text-wrap.ts +261 -48
  305. package/src/visualize/index.tsx +1 -1
  306. package/src/renderer/lifecycle/ResizeManager.ts +0 -65
  307. package/src/renderer/lifecycle/index.ts +0 -4
  308. /package/src/{renderer → internal/renderer}/core/RendererState.ts +0 -0
  309. /package/src/{renderer → internal/renderer}/core/index.ts +0 -0
  310. /package/src/{renderer → internal/renderer}/input/index.ts +0 -0
  311. /package/src/{renderer → internal/renderer}/lifecycle/RenderCache.ts +0 -0
  312. /package/src/{renderer → internal/renderer}/modes/FullscreenRenderer.ts +0 -0
  313. /package/src/{renderer → internal/renderer}/modes/index.ts +0 -0
@@ -1,13 +1,14 @@
1
- import { type Color, Colors, displayWidth, graphemes } from "@effect-tui/core"
1
+ import { type Color, Colors, graphemes } from "@effect-tui/core"
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react"
3
3
  import { useKeyboard } from "../hooks/use-keyboard.js"
4
4
  import { usePaste } from "../hooks/use-paste.js"
5
- import { isWhitespace } from "../utils/word-boundaries.js"
5
+ import { buildTextLayout, type TextLayout } from "../utils/index.js"
6
6
  import type { DrawContext } from "../hosts/canvas.js"
7
7
  import {
8
8
  deleteCharBackwardMultiline,
9
9
  deleteCharForwardMultiline,
10
10
  deleteWordBackwardMultiline,
11
+ deleteWordForwardMultiline,
11
12
  graphemeColToCharIdx,
12
13
  insertTextMultiline,
13
14
  killToEndMultiline,
@@ -15,6 +16,8 @@ import {
15
16
  type MultilineState,
16
17
  matchNextWord,
17
18
  matchPrevWord,
19
+ resolveTextInputAction,
20
+ type TextKeyEvent,
18
21
  transposeCharsMultiline,
19
22
  } from "./text-editing.js"
20
23
 
@@ -61,108 +64,11 @@ interface CursorPos {
61
64
  col: number // grapheme index within logical line
62
65
  }
63
66
 
64
- /** A visual line that maps back to a logical line */
65
- interface VisualLine {
66
- logicalRow: number
67
- startCol: number // grapheme index in logical line (inclusive)
68
- endCol: number // grapheme index in logical line (exclusive)
69
- text: string // the actual text for this visual line
70
- }
71
-
72
- /** Layout for a single logical line */
73
- interface LineLayout {
74
- graphemeList: string[]
75
- widths: number[]
76
- prefixWidths: number[] // cumulative widths, length = graphemes.length + 1
77
- visualLines: VisualLine[]
78
- }
79
-
80
- /** Complete layout for all text */
81
- interface Layout {
82
- lines: LineLayout[]
83
- allVisualLines: VisualLine[]
84
- }
85
-
86
- /**
87
- * Wrap a single logical line into visual lines.
88
- * Uses word boundaries with fallback to character wrap.
89
- */
90
- function wrapLogicalLine(line: string, logicalRow: number, maxWidth: number): LineLayout {
91
- const graphemeList = graphemes(line)
92
- const widths = graphemeList.map((g) => displayWidth(g))
93
-
94
- // Build prefix widths for O(1) range width queries
95
- const prefixWidths: number[] = [0]
96
- for (let i = 0; i < widths.length; i++) {
97
- prefixWidths.push(prefixWidths[i] + widths[i])
98
- }
99
-
100
- // Find break opportunities (after spaces)
101
- const isBreakable = graphemeList.map((g) => isWhitespace(g))
102
-
103
- const visualLines: VisualLine[] = []
104
- let start = 0
105
- let currentWidth = 0
106
- let lastBreak = -1
107
-
108
- for (let i = 0; i < graphemeList.length; i++) {
109
- const w = widths[i]
110
-
111
- if (currentWidth + w > maxWidth && start < i) {
112
- // Need to wrap - backtrack to last break if possible
113
- const breakAt = lastBreak >= start ? lastBreak + 1 : i
114
-
115
- visualLines.push({
116
- logicalRow,
117
- startCol: start,
118
- endCol: breakAt,
119
- text: graphemeList.slice(start, breakAt).join(""),
120
- })
121
-
122
- start = breakAt
123
- currentWidth = prefixWidths[i + 1] - prefixWidths[start]
124
- lastBreak = -1
125
- } else {
126
- currentWidth += w
127
- }
128
-
129
- if (isBreakable[i]) {
130
- lastBreak = i
131
- }
132
- }
133
-
134
- // Final segment
135
- visualLines.push({
136
- logicalRow,
137
- startCol: start,
138
- endCol: graphemeList.length,
139
- text: graphemeList.slice(start).join(""),
140
- })
141
-
142
- return { graphemeList, widths, prefixWidths, visualLines }
143
- }
144
-
145
- /**
146
- * Build complete layout for text with word wrap.
147
- */
148
- function buildLayout(text: string, maxWidth: number): Layout {
149
- const logicalLines = text.split("\n")
150
- const lines: LineLayout[] = []
151
- const allVisualLines: VisualLine[] = []
152
-
153
- for (let row = 0; row < logicalLines.length; row++) {
154
- const lineLayout = wrapLogicalLine(logicalLines[row], row, maxWidth)
155
- lines.push(lineLayout)
156
- allVisualLines.push(...lineLayout.visualLines)
157
- }
158
-
159
- return { lines, allVisualLines }
160
- }
161
67
 
162
68
  /**
163
69
  * Convert logical cursor to visual row/col.
164
70
  */
165
- function logicalToVisual(layout: Layout, cursor: CursorPos): { visualRow: number; visualCol: number } {
71
+ function logicalToVisual(layout: TextLayout, cursor: CursorPos): { visualRow: number; visualCol: number } {
166
72
  const lineLayout = layout.lines[cursor.row]
167
73
  if (!lineLayout) {
168
74
  return { visualRow: 0, visualCol: 0 }
@@ -196,7 +102,7 @@ function logicalToVisual(layout: Layout, cursor: CursorPos): { visualRow: number
196
102
  * Convert visual row to logical cursor (for up/down navigation).
197
103
  * Tries to preserve the visual X position.
198
104
  */
199
- function visualToLogical(layout: Layout, visualRow: number, targetVisualX: number): CursorPos {
105
+ function visualToLogical(layout: TextLayout, visualRow: number, targetVisualX: number): CursorPos {
200
106
  if (visualRow < 0) {
201
107
  return { row: 0, col: 0 }
202
108
  }
@@ -294,38 +200,9 @@ export function MultilineTextInput({
294
200
 
295
201
  // Build layout with word wrap
296
202
  const layout = useMemo(() => {
297
- if (!wordWrap) {
298
- // No wrap - each logical line is one visual line
299
- const lines: LineLayout[] = logicalLines.map((line, row) => {
300
- const graphemeList = graphemes(line)
301
- const widths = graphemeList.map((g) => displayWidth(g))
302
- const prefixWidths = [0]
303
- for (let i = 0; i < widths.length; i++) {
304
- prefixWidths.push(prefixWidths[i] + widths[i])
305
- }
306
- return {
307
- graphemeList,
308
- widths,
309
- prefixWidths,
310
- visualLines: [
311
- {
312
- logicalRow: row,
313
- startCol: 0,
314
- endCol: graphemeList.length,
315
- text: line,
316
- },
317
- ],
318
- }
319
- })
320
- return {
321
- lines,
322
- allVisualLines: lines.flatMap((l) => l.visualLines),
323
- }
324
- }
325
-
326
203
  const effectiveWidth = Math.max(10, contentWidth - lineNumGutterWidth)
327
- return buildLayout(value, effectiveWidth)
328
- }, [value, contentWidth, wordWrap, lineNumGutterWidth, logicalLines])
204
+ return buildTextLayout(value, effectiveWidth, { wrap: wordWrap, preserveWhitespace: true })
205
+ }, [value, contentWidth, wordWrap, lineNumGutterWidth])
329
206
 
330
207
  // Keep cursor in bounds when value changes externally
331
208
  useEffect(() => {
@@ -364,9 +241,22 @@ export function MultilineTextInput({
364
241
  const currentLineLayout = layout.lines[cursor.row]
365
242
 
366
243
  const handleKey = useCallback(
367
- (key: { name: string; text?: string; ctrl?: boolean; meta?: boolean; shift?: boolean }) => {
244
+ (key: TextKeyEvent) => {
368
245
  if (!focused) return
369
246
 
247
+ const action = resolveTextInputAction(key)
248
+ if (!action) return
249
+
250
+ const applyEdit = (result: { state: MultilineState; changed: boolean }, options?: { keepKillRing?: boolean }) => {
251
+ if (result.changed) {
252
+ onChange(result.state.lines.join("\n"))
253
+ setCursor(result.state.cursor)
254
+ if (!options?.keepKillRing && result.state.killRing !== killRing) {
255
+ setKillRing(result.state.killRing)
256
+ }
257
+ }
258
+ }
259
+
370
260
  const moveCursor = (row: number, col: number) => {
371
261
  const newRow = Math.max(0, Math.min(logicalLines.length - 1, row))
372
262
  const lineLayout = layout.lines[newRow]
@@ -385,92 +275,93 @@ export function MultilineTextInput({
385
275
  setCursor(newCursor)
386
276
  }
387
277
 
388
- switch (key.name) {
389
- case "up":
390
- if (key.meta) {
391
- // Option+Up: Move to start of document
392
- moveCursor(0, 0)
393
- } else {
394
- moveVisual(-1)
278
+ switch (action.type) {
279
+ case "move-up":
280
+ moveVisual(-1)
281
+ break
282
+ case "move-down":
283
+ moveVisual(1)
284
+ break
285
+ case "move-doc-start":
286
+ moveCursor(0, 0)
287
+ break
288
+ case "move-doc-end": {
289
+ const lastRow = logicalLines.length - 1
290
+ const lastLineLen = layout.lines[lastRow]?.graphemeList.length ?? 0
291
+ moveCursor(lastRow, lastLineLen)
292
+ break
293
+ }
294
+ case "move-left":
295
+ if (cursor.col > 0) {
296
+ moveCursor(cursor.row, cursor.col - 1)
297
+ } else if (cursor.row > 0) {
298
+ const prevLineLen = layout.lines[cursor.row - 1]?.graphemeList.length ?? 0
299
+ moveCursor(cursor.row - 1, prevLineLen)
395
300
  }
396
301
  break
397
-
398
- case "down":
399
- if (key.meta) {
400
- // Option+Down: Move to end of document
401
- const lastRow = logicalLines.length - 1
402
- const lastLineLen = layout.lines[lastRow]?.graphemeList.length ?? 0
403
- moveCursor(lastRow, lastLineLen)
404
- } else {
405
- moveVisual(1)
302
+ case "move-right": {
303
+ const lineLen = currentLineLayout?.graphemeList.length ?? 0
304
+ if (cursor.col < lineLen) {
305
+ moveCursor(cursor.row, cursor.col + 1)
306
+ } else if (cursor.row < logicalLines.length - 1) {
307
+ moveCursor(cursor.row + 1, 0)
406
308
  }
407
309
  break
408
-
409
- case "left":
410
- if (key.meta) {
411
- // Option+Left: Move to previous word boundary
412
- const lineLayout = layout.lines[cursor.row]
413
- if (lineLayout) {
414
- const beforeCursor = lineLayout.graphemeList.slice(0, cursor.col).join("")
415
- const match = matchPrevWord(beforeCursor)
416
- if (match) {
417
- // Count graphemes in match
418
- const matchGraphemes = graphemes(match).length
419
- moveCursor(cursor.row, cursor.col - matchGraphemes)
420
- } else if (cursor.col > 0) {
421
- moveCursor(cursor.row, 0)
422
- } else if (cursor.row > 0) {
423
- const prevLineLen = layout.lines[cursor.row - 1]?.graphemeList.length ?? 0
424
- moveCursor(cursor.row - 1, prevLineLen)
425
- }
426
- }
427
- } else {
428
- if (cursor.col > 0) {
429
- moveCursor(cursor.row, cursor.col - 1)
310
+ }
311
+ case "move-word-left": {
312
+ const lineLayout = layout.lines[cursor.row]
313
+ if (lineLayout) {
314
+ const beforeCursor = lineLayout.graphemeList.slice(0, cursor.col).join("")
315
+ const match = matchPrevWord(beforeCursor)
316
+ if (match) {
317
+ const matchGraphemes = graphemes(match).length
318
+ moveCursor(cursor.row, cursor.col - matchGraphemes)
319
+ } else if (cursor.col > 0) {
320
+ moveCursor(cursor.row, 0)
430
321
  } else if (cursor.row > 0) {
431
322
  const prevLineLen = layout.lines[cursor.row - 1]?.graphemeList.length ?? 0
432
323
  moveCursor(cursor.row - 1, prevLineLen)
433
324
  }
434
325
  }
435
326
  break
436
-
437
- case "right":
438
- if (key.meta) {
439
- // Option+Right: Move to next word boundary
440
- const lineLayout = layout.lines[cursor.row]
441
- if (lineLayout) {
442
- const afterCursor = lineLayout.graphemeList.slice(cursor.col).join("")
443
- const match = matchNextWord(afterCursor)
444
- if (match) {
445
- const matchGraphemes = graphemes(match).length
446
- moveCursor(cursor.row, cursor.col + matchGraphemes)
447
- } else if (cursor.col < lineLayout.graphemeList.length) {
448
- moveCursor(cursor.row, lineLayout.graphemeList.length)
449
- } else if (cursor.row < logicalLines.length - 1) {
450
- moveCursor(cursor.row + 1, 0)
451
- }
452
- }
453
- } else {
454
- const lineLen = currentLineLayout?.graphemeList.length ?? 0
455
- if (cursor.col < lineLen) {
456
- moveCursor(cursor.row, cursor.col + 1)
327
+ }
328
+ case "move-word-right": {
329
+ const lineLayout = layout.lines[cursor.row]
330
+ if (lineLayout) {
331
+ const afterCursor = lineLayout.graphemeList.slice(cursor.col).join("")
332
+ const match = matchNextWord(afterCursor)
333
+ if (match) {
334
+ const matchGraphemes = graphemes(match).length
335
+ moveCursor(cursor.row, cursor.col + matchGraphemes)
336
+ } else if (cursor.col < lineLayout.graphemeList.length) {
337
+ moveCursor(cursor.row, lineLayout.graphemeList.length)
457
338
  } else if (cursor.row < logicalLines.length - 1) {
458
339
  moveCursor(cursor.row + 1, 0)
459
340
  }
460
341
  }
461
342
  break
462
-
463
- case "home":
343
+ }
344
+ case "move-start":
464
345
  moveCursor(cursor.row, 0)
465
346
  break
466
-
467
- case "end":
347
+ case "move-end":
468
348
  moveCursor(cursor.row, currentLineLayout?.graphemeList.length ?? 0)
469
349
  break
470
-
471
- case "backspace":
472
- if (key.meta && cursor.col > 0) {
473
- // Option+Backspace: Delete previous word
350
+ case "delete-backward": {
351
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
352
+ applyEdit(deleteCharBackwardMultiline(state))
353
+ break
354
+ }
355
+ case "delete-forward": {
356
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
357
+ applyEdit(deleteCharForwardMultiline(state))
358
+ break
359
+ }
360
+ case "delete-word-backward":
361
+ if (action.scope === "document") {
362
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
363
+ applyEdit(deleteWordBackwardMultiline(state))
364
+ } else if (cursor.col > 0) {
474
365
  const charIdx = graphemeColToCharIdx(currentLine, cursor.col)
475
366
  const beforeCursor = currentLine.slice(0, charIdx)
476
367
  const match = matchPrevWord(beforeCursor)
@@ -484,157 +375,55 @@ export function MultilineTextInput({
484
375
  setCursor({ row: cursor.row, col: newCol })
485
376
  }
486
377
  } else {
487
- // Delete character before cursor (or join with previous line)
488
378
  const state: MultilineState = { lines: logicalLines, cursor, killRing }
489
- const result = deleteCharBackwardMultiline(state)
490
- if (result.changed) {
491
- onChange(result.state.lines.join("\n"))
492
- setCursor(result.state.cursor)
493
- }
379
+ applyEdit(deleteCharBackwardMultiline(state), { keepKillRing: true })
494
380
  }
495
381
  break
496
-
497
- case "delete": {
382
+ case "delete-word-forward": {
498
383
  const state: MultilineState = { lines: logicalLines, cursor, killRing }
499
- const result = deleteCharForwardMultiline(state)
500
- if (result.changed) {
501
- onChange(result.state.lines.join("\n"))
384
+ const lineLen = currentLineLayout?.graphemeList.length ?? 0
385
+ if (cursor.col >= lineLen) {
386
+ applyEdit(deleteCharForwardMultiline(state), { keepKillRing: true })
387
+ break
502
388
  }
389
+ applyEdit(deleteWordForwardMultiline(state), { keepKillRing: action.scope === "line" })
503
390
  break
504
391
  }
505
-
506
- case "enter":
507
- if (key.ctrl || key.meta) {
508
- onSubmit?.(value)
509
- } else {
510
- // Insert newline
511
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
512
- const result = insertTextMultiline(state, "\n")
513
- if (result.changed) {
514
- onChange(result.state.lines.join("\n"))
515
- setCursor(result.state.cursor)
516
- }
517
- }
392
+ case "kill-to-end": {
393
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
394
+ applyEdit(killToEndMultiline(state))
518
395
  break
519
-
520
- case "escape":
521
- onCancel?.()
396
+ }
397
+ case "kill-to-start": {
398
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
399
+ applyEdit(killToStartMultiline(state))
522
400
  break
523
-
524
- case "char":
525
- case "space":
526
- if (key.ctrl && key.text) {
527
- // Emacs-style keybindings
528
- switch (key.text) {
529
- case "a":
530
- moveCursor(cursor.row, 0)
531
- break
532
- case "e":
533
- moveCursor(cursor.row, currentLineLayout?.graphemeList.length ?? 0)
534
- break
535
- case "b":
536
- if (cursor.col > 0) {
537
- moveCursor(cursor.row, cursor.col - 1)
538
- } else if (cursor.row > 0) {
539
- const prevLen = layout.lines[cursor.row - 1]?.graphemeList.length ?? 0
540
- moveCursor(cursor.row - 1, prevLen)
541
- }
542
- break
543
- case "f":
544
- if (cursor.col < (currentLineLayout?.graphemeList.length ?? 0)) {
545
- moveCursor(cursor.row, cursor.col + 1)
546
- } else if (cursor.row < logicalLines.length - 1) {
547
- moveCursor(cursor.row + 1, 0)
548
- }
549
- break
550
- case "n":
551
- moveVisual(1)
552
- break
553
- case "p":
554
- moveVisual(-1)
555
- break
556
- case "d": {
557
- // Delete character at cursor
558
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
559
- const result = deleteCharForwardMultiline(state)
560
- if (result.changed) {
561
- onChange(result.state.lines.join("\n"))
562
- }
563
- break
564
- }
565
- case "h": {
566
- // Backspace
567
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
568
- const result = deleteCharBackwardMultiline(state)
569
- if (result.changed) {
570
- onChange(result.state.lines.join("\n"))
571
- setCursor(result.state.cursor)
572
- }
573
- break
574
- }
575
- case "k": {
576
- // Kill to end of line
577
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
578
- const result = killToEndMultiline(state)
579
- if (result.changed) {
580
- onChange(result.state.lines.join("\n"))
581
- setCursor(result.state.cursor)
582
- setKillRing(result.state.killRing)
583
- }
584
- break
585
- }
586
- case "u": {
587
- // Kill to start of line
588
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
589
- const result = killToStartMultiline(state)
590
- if (result.changed) {
591
- onChange(result.state.lines.join("\n"))
592
- setCursor(result.state.cursor)
593
- setKillRing(result.state.killRing)
594
- }
595
- break
596
- }
597
- case "w": {
598
- // Delete word backward (crosses line boundaries)
599
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
600
- const result = deleteWordBackwardMultiline(state)
601
- if (result.changed) {
602
- onChange(result.state.lines.join("\n"))
603
- setCursor(result.state.cursor)
604
- setKillRing(result.state.killRing)
605
- }
606
- break
607
- }
608
- case "t": {
609
- // Transpose characters
610
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
611
- const result = transposeCharsMultiline(state)
612
- if (result.changed) {
613
- onChange(result.state.lines.join("\n"))
614
- setCursor(result.state.cursor)
615
- }
616
- break
617
- }
618
- case "y": {
619
- // Yank from kill ring
620
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
621
- const result = insertTextMultiline(state, killRing)
622
- if (result.changed) {
623
- onChange(result.state.lines.join("\n"))
624
- setCursor(result.state.cursor)
625
- }
626
- break
627
- }
628
- }
629
- } else if (key.text && !key.meta) {
630
- // Insert character at cursor
631
- const state: MultilineState = { lines: logicalLines, cursor, killRing }
632
- const result = insertTextMultiline(state, key.text)
633
- if (result.changed) {
634
- onChange(result.state.lines.join("\n"))
635
- setCursor(result.state.cursor)
636
- }
637
- }
401
+ }
402
+ case "transpose": {
403
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
404
+ applyEdit(transposeCharsMultiline(state))
405
+ break
406
+ }
407
+ case "yank": {
408
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
409
+ applyEdit(insertTextMultiline(state, killRing))
410
+ break
411
+ }
412
+ case "insert": {
413
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
414
+ applyEdit(insertTextMultiline(state, action.text))
415
+ break
416
+ }
417
+ case "enter": {
418
+ const state: MultilineState = { lines: logicalLines, cursor, killRing }
419
+ applyEdit(insertTextMultiline(state, "\n"))
420
+ break
421
+ }
422
+ case "submit":
423
+ onSubmit?.(value)
424
+ break
425
+ case "cancel":
426
+ onCancel?.()
638
427
  break
639
428
  }
640
429
  },
@@ -684,11 +473,16 @@ export function MultilineTextInput({
684
473
 
685
474
  // Clear with background
686
475
  if (bg !== undefined) {
687
- ctx.fill(0, 0, ctx.width, ctx.height, " ", { bg })
476
+ ctx.fillRect(0, 0, ctx.width, ctx.height, " ", { bg })
688
477
  }
689
478
 
690
479
  // Use placeholder layout if empty
691
- const displayLayout = isPlaceholder ? buildLayout(placeholder, Math.max(10, ctx.width - lineNumWidth)) : layout
480
+ const displayLayout = isPlaceholder
481
+ ? buildTextLayout(placeholder, Math.max(10, ctx.width - lineNumWidth), {
482
+ wrap: true,
483
+ preserveWhitespace: true,
484
+ })
485
+ : layout
692
486
 
693
487
  const { visualRow: cursorVisualRow } = isPlaceholder ? { visualRow: 0 } : logicalToVisual(layout, cursor)
694
488