@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
@@ -4,15 +4,32 @@ import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.j
4
4
  import { fillRectWithInheritedBg } from "../utils/index.js"
5
5
  import { SingleChildHost } from "./single-child.js"
6
6
 
7
+ export type ScrollAxis = "vertical" | "horizontal" | "both"
8
+ export type ScrollAlignX = "left" | "center" | "right"
9
+ export type ScrollAlignY = "top" | "center" | "bottom"
10
+ export type ScrollAlign = ScrollAlignX | ScrollAlignY | { x?: ScrollAlignX; y?: ScrollAlignY }
11
+ export interface ScrollLayoutChange {
12
+ content: { width: number; height: number }
13
+ viewport: { width: number; height: number }
14
+ offset: { x: number; y: number }
15
+ rect: { x: number; y: number; w: number; h: number }
16
+ axis: ScrollAxis
17
+ }
18
+
7
19
  export interface ScrollProps extends CommonProps {
8
20
  /** Scroll axis: "vertical" (default), "horizontal", or "both" */
9
- axis?: "vertical" | "horizontal" | "both"
10
- /** Current scroll offset in pixels (0 = start) */
11
- offset?: number
12
- /** Horizontal offset when axis="both" */
21
+ axis?: ScrollAxis
22
+ /** Vertical scroll offset in pixels (0 = top) */
23
+ offsetY?: number
24
+ /** Horizontal scroll offset in pixels (0 = left) */
13
25
  offsetX?: number
14
- /** Alignment when content is smaller than viewport */
15
- align?: "start" | "end"
26
+ /**
27
+ * Alignment when content is smaller than viewport.
28
+ * - axis="vertical": "top" | "center" | "bottom"
29
+ * - axis="horizontal": "left" | "center" | "right"
30
+ * - axis="both": { x, y }
31
+ */
32
+ align?: ScrollAlign
16
33
  /** Background color for the scroll viewport */
17
34
  bg?: Color
18
35
  /** Whether to show scrollbar indicators */
@@ -22,38 +39,53 @@ export interface ScrollProps extends CommonProps {
22
39
  * This is handled in the host itself for instant updates (no React roundtrip).
23
40
  */
24
41
  sticky?: boolean
25
- /** Called when content size is measured (for useScroll) */
26
- onContentSize?: (width: number, height: number) => void
27
- /** Called when viewport size changes (for useScroll) */
28
- onViewportSize?: (width: number, height: number) => void
29
- /** Called when effective offset changes (for syncing with useScroll when sticky adjusts) */
30
- onEffectiveOffset?: (offset: number) => void
31
- /** Called when effective horizontal offset changes */
32
- onEffectiveOffsetX?: (offsetX: number) => void
33
- /** Called when layout rect changes (for hit testing) */
34
- onRect?: (x: number, y: number, w: number, h: number) => void
42
+ /** Called when scroll layout changes (content/viewport/offset/rect). */
43
+ onScrollLayoutChange?: (event: ScrollLayoutChange) => void
44
+ }
45
+
46
+ const isAlignX = (value: string): value is ScrollAlignX => value === "left" || value === "center" || value === "right"
47
+ const isAlignY = (value: string): value is ScrollAlignY => value === "top" || value === "center" || value === "bottom"
48
+
49
+ const resolveAlign = (align: ScrollAlign | undefined): { x: ScrollAlignX; y: ScrollAlignY } => {
50
+ let x: ScrollAlignX | undefined
51
+ let y: ScrollAlignY | undefined
52
+
53
+ if (typeof align === "string") {
54
+ if (isAlignX(align)) x = align
55
+ if (isAlignY(align)) y = align
56
+ } else if (align) {
57
+ x = align.x
58
+ y = align.y
59
+ }
60
+
61
+ return {
62
+ x: x ?? "left",
63
+ y: y ?? "top",
64
+ }
35
65
  }
36
66
 
37
67
  export class ScrollHost extends SingleChildHost {
38
- axis: "vertical" | "horizontal" | "both" = "vertical"
39
- offset = 0
68
+ axis: ScrollAxis = "vertical"
69
+ offsetY = 0
40
70
  offsetX = 0
41
- align: "start" | "end" = "start"
71
+ align?: ScrollAlign
42
72
  bg?: Color
43
73
  showScrollbar = true
44
74
  sticky = false
45
75
 
46
76
  // Scroll is greedy by default - expands to fill available space
47
77
  override greedy: boolean | number | undefined = 1
48
- onContentSize?: (width: number, height: number) => void
49
- onViewportSize?: (width: number, height: number) => void
50
- onEffectiveOffset?: (offset: number) => void
51
- onEffectiveOffsetX?: (offsetX: number) => void
52
- onRect?: (x: number, y: number, w: number, h: number) => void
78
+ onScrollLayoutChange?: (event: ScrollLayoutChange) => void
53
79
 
54
80
  // Measured content dimensions (full size before clipping)
55
81
  private contentWidth = 0
56
82
  private contentHeight = 0
83
+ // Effective viewport dimensions (excludes scrollbar gutter when visible)
84
+ private viewportWidth = 0
85
+ private viewportHeight = 0
86
+ // Scrollbar visibility after accounting for content overflow
87
+ private showVerticalScrollbar = false
88
+ private showHorizontalScrollbar = false
57
89
  // Track last reported sizes to avoid redundant callbacks
58
90
  private lastReportedContentW = -1
59
91
  private lastReportedContentH = -1
@@ -63,6 +95,8 @@ export class ScrollHost extends SingleChildHost {
63
95
  private lastRectY = -1
64
96
  private lastRectW = -1
65
97
  private lastRectH = -1
98
+ private lastOffsetY = -1
99
+ private lastOffsetX = -1
66
100
  // Track if we were at end (for sticky behavior)
67
101
  private wasAtEnd = true
68
102
  private wasAtEndX = true
@@ -75,78 +109,129 @@ export class ScrollHost extends SingleChildHost {
75
109
  this.updateProps(props as unknown as Record<string, unknown>)
76
110
  }
77
111
 
78
- measure(maxW: number, maxH: number): Size {
112
+ private computeViewport(baseW: number, baseH: number, contentW: number, contentH: number): {
113
+ viewportW: number
114
+ viewportH: number
115
+ showV: boolean
116
+ showH: boolean
117
+ } {
118
+ const allowV = this.showScrollbar && (this.axis === "vertical" || this.axis === "both")
119
+ const allowH = this.showScrollbar && (this.axis === "horizontal" || this.axis === "both")
120
+ let viewportW = Math.max(0, baseW)
121
+ let viewportH = Math.max(0, baseH)
122
+ let showV = false
123
+ let showH = false
124
+
125
+ for (let i = 0; i < 2; i++) {
126
+ const nextShowV = allowV && contentH > viewportH
127
+ const nextShowH = allowH && contentW > viewportW
128
+ if (nextShowV === showV && nextShowH === showH) break
129
+ showV = nextShowV
130
+ showH = nextShowH
131
+ viewportW = Math.max(0, baseW - (showV ? 1 : 0))
132
+ viewportH = Math.max(0, baseH - (showH ? 1 : 0))
133
+ }
134
+
135
+ return { viewportW, viewportH, showV, showH }
136
+ }
137
+
138
+ protected measureSelf(maxW: number, maxH: number): Size {
79
139
  // Apply frame constraints to determine our size
80
140
  const constrained = this.constrainProposal(maxW, maxH)
81
141
 
82
- // Measure child with unbounded dimension(s) based on axis
83
- let childMaxW = constrained.w
84
- let childMaxH = constrained.h
85
-
86
- if (this.axis === "vertical" || this.axis === "both") {
87
- childMaxH = Number.MAX_SAFE_INTEGER
142
+ const child = this.child
143
+ if (!child) {
144
+ this.contentWidth = 0
145
+ this.contentHeight = 0
146
+ const viewport = this.computeViewport(constrained.w, constrained.h, 0, 0)
147
+ this.viewportWidth = viewport.viewportW
148
+ this.viewportHeight = viewport.viewportH
149
+ this.showVerticalScrollbar = viewport.showV
150
+ this.showHorizontalScrollbar = viewport.showH
151
+ return this.constrainResult({ w: 0, h: 0 })
88
152
  }
89
- if (this.axis === "horizontal" || this.axis === "both") {
90
- childMaxW = Number.MAX_SAFE_INTEGER
153
+
154
+ const measureChild = (proposalW: number, proposalH: number): Size => {
155
+ let childMaxW = proposalW
156
+ let childMaxH = proposalH
157
+
158
+ if (this.axis === "vertical" || this.axis === "both") {
159
+ childMaxH = Number.MAX_SAFE_INTEGER
160
+ }
161
+ if (this.axis === "horizontal" || this.axis === "both") {
162
+ childMaxW = Number.MAX_SAFE_INTEGER
163
+ }
164
+
165
+ return child.measure(childMaxW, childMaxH)
91
166
  }
92
167
 
93
- // Measure single child (scroll should have at most one child)
94
- const child = this.child
95
- if (child) {
96
- const childSize = child.measure(childMaxW, childMaxH)
97
- this.contentWidth = childSize.w
98
- this.contentHeight = childSize.h
99
- // Note: onContentSize callback is deferred to layout() to keep measure() pure
168
+ let viewportW = constrained.w
169
+ let viewportH = constrained.h
170
+ let contentW = 0
171
+ let contentH = 0
172
+ let showV = false
173
+ let showH = false
174
+
175
+ for (let i = 0; i < 3; i++) {
176
+ const childSize = measureChild(viewportW, viewportH)
177
+ contentW = childSize.w
178
+ contentH = childSize.h
179
+ const viewport = this.computeViewport(constrained.w, constrained.h, contentW, contentH)
180
+ if (viewport.viewportW === viewportW && viewport.viewportH === viewportH) {
181
+ showV = viewport.showV
182
+ showH = viewport.showH
183
+ break
184
+ }
185
+ viewportW = viewport.viewportW
186
+ viewportH = viewport.viewportH
187
+ showV = viewport.showV
188
+ showH = viewport.showH
100
189
  }
101
190
 
191
+ this.contentWidth = contentW
192
+ this.contentHeight = contentH
193
+ this.viewportWidth = viewportW
194
+ this.viewportHeight = viewportH
195
+ this.showVerticalScrollbar = showV
196
+ this.showHorizontalScrollbar = showH
197
+
102
198
  // Report natural content size (clamped to constrained bounds)
103
199
  // Greedy expansion happens in layout phase via layoutFlex
104
- const naturalW = Math.min(this.contentWidth, constrained.w)
105
- const naturalH = Math.min(this.contentHeight, constrained.h)
200
+ const naturalW = Math.min(this.contentWidth + (this.showVerticalScrollbar ? 1 : 0), constrained.w)
201
+ const naturalH = Math.min(this.contentHeight + (this.showHorizontalScrollbar ? 1 : 0), constrained.h)
106
202
 
107
203
  return this.constrainResult({ w: naturalW, h: naturalH })
108
204
  }
109
205
 
110
- override layout(rect: Rect): void {
206
+ protected override layoutSelf(rect: Rect): void {
111
207
  const layoutRect = this.layoutWithConstraints(rect)
112
-
113
- // Report content size if changed (deferred from measure() to keep it pure)
114
- if (this.contentWidth !== this.lastReportedContentW || this.contentHeight !== this.lastReportedContentH) {
115
- this.lastReportedContentW = this.contentWidth
116
- this.lastReportedContentH = this.contentHeight
117
- this.onContentSize?.(this.contentWidth, this.contentHeight)
118
- }
119
-
120
- // Report viewport size if changed (for useScroll hook)
121
- if (this.onViewportSize && (layoutRect.w !== this.lastViewportW || layoutRect.h !== this.lastViewportH)) {
122
- this.lastViewportW = layoutRect.w
123
- this.lastViewportH = layoutRect.h
124
- this.onViewportSize(layoutRect.w, layoutRect.h)
125
- }
126
-
127
- // Report rect if position changed (for hit testing)
128
- if (
208
+ const viewport = this.computeViewport(layoutRect.w, layoutRect.h, this.contentWidth, this.contentHeight)
209
+ this.viewportWidth = viewport.viewportW
210
+ this.viewportHeight = viewport.viewportH
211
+ this.showVerticalScrollbar = viewport.showV
212
+ this.showHorizontalScrollbar = viewport.showH
213
+
214
+ const { x: alignX, y: alignY } = resolveAlign(this.align)
215
+
216
+ const contentChanged =
217
+ this.contentWidth !== this.lastReportedContentW || this.contentHeight !== this.lastReportedContentH
218
+ const viewportChanged =
219
+ this.viewportWidth !== this.lastViewportW || this.viewportHeight !== this.lastViewportH
220
+ const rectChanged =
129
221
  layoutRect.x !== this.lastRectX ||
130
222
  layoutRect.y !== this.lastRectY ||
131
223
  layoutRect.w !== this.lastRectW ||
132
224
  layoutRect.h !== this.lastRectH
133
- ) {
134
- this.lastRectX = layoutRect.x
135
- this.lastRectY = layoutRect.y
136
- this.lastRectW = layoutRect.w
137
- this.lastRectH = layoutRect.h
138
- this.onRect?.(layoutRect.x, layoutRect.y, layoutRect.w, layoutRect.h)
139
- }
140
225
 
141
226
  const child = this.child
142
227
  if (!child) return
143
228
 
144
229
  // Calculate max scroll offsets
145
- const maxScrollY = Math.max(0, this.contentHeight - layoutRect.h)
146
- const maxScrollX = Math.max(0, this.contentWidth - layoutRect.w)
230
+ const maxScrollY = Math.max(0, this.contentHeight - this.viewportHeight)
231
+ const maxScrollX = Math.max(0, this.contentWidth - this.viewportWidth)
147
232
 
148
233
  // Start with the offset from props (controlled by useScroll)
149
- let scrollY = this.offset
234
+ let scrollY = this.offsetY
150
235
  let scrollX = this.offsetX
151
236
 
152
237
  // Sticky scroll logic (vertical):
@@ -154,7 +239,7 @@ export class ScrollHost extends SingleChildHost {
154
239
  // - If at end (or was at end and content grew), stay stuck
155
240
  if (this.sticky && (this.axis === "vertical" || this.axis === "both")) {
156
241
  // Detect if user scrolled away (offset prop is less than where we rendered)
157
- const userScrolledAway = this.offset < this.effectiveOffset - 1
242
+ const userScrolledAway = this.offsetY < this.effectiveOffset - 1
158
243
 
159
244
  if (userScrolledAway) {
160
245
  // User scrolled up - unstick
@@ -185,25 +270,23 @@ export class ScrollHost extends SingleChildHost {
185
270
  scrollX = Math.max(0, Math.min(maxScrollX, scrollX))
186
271
 
187
272
  // Store effective offsets for rendering (scrollbar position)
188
- // Report back if clamped or changed (keep controller in sync)
189
- if (scrollY !== this.effectiveOffset || scrollY !== this.offset) {
190
- this.onEffectiveOffset?.(scrollY)
191
- }
192
- if (scrollX !== this.effectiveOffsetX || scrollX !== this.offsetX) {
193
- this.onEffectiveOffsetX?.(scrollX)
194
- }
195
273
  this.effectiveOffset = scrollY
196
274
  this.effectiveOffsetX = scrollX
275
+ const offsetChanged = this.effectiveOffset !== this.lastOffsetY || this.effectiveOffsetX !== this.lastOffsetX
197
276
 
198
277
  // Handle alignment when content is smaller than viewport
199
- if (this.align === "end") {
200
- if (this.contentHeight < layoutRect.h && (this.axis === "vertical" || this.axis === "both")) {
201
- // Align to bottom
202
- scrollY = -(layoutRect.h - this.contentHeight)
278
+ if (this.contentHeight < this.viewportHeight && (this.axis === "vertical" || this.axis === "both")) {
279
+ if (alignY === "bottom") {
280
+ scrollY = -(this.viewportHeight - this.contentHeight)
281
+ } else if (alignY === "center") {
282
+ scrollY = -Math.floor((this.viewportHeight - this.contentHeight) / 2)
203
283
  }
204
- if (this.contentWidth < layoutRect.w && (this.axis === "horizontal" || this.axis === "both")) {
205
- // Align to right
206
- scrollX = -(layoutRect.w - this.contentWidth)
284
+ }
285
+ if (this.contentWidth < this.viewportWidth && (this.axis === "horizontal" || this.axis === "both")) {
286
+ if (alignX === "right") {
287
+ scrollX = -(this.viewportWidth - this.contentWidth)
288
+ } else if (alignX === "center") {
289
+ scrollX = -Math.floor((this.viewportWidth - this.contentWidth) / 2)
207
290
  }
208
291
  }
209
292
 
@@ -215,6 +298,27 @@ export class ScrollHost extends SingleChildHost {
215
298
  h: this.contentHeight,
216
299
  }
217
300
  child.layout(childRect)
301
+
302
+ if (this.onScrollLayoutChange && (contentChanged || viewportChanged || rectChanged || offsetChanged)) {
303
+ this.lastReportedContentW = this.contentWidth
304
+ this.lastReportedContentH = this.contentHeight
305
+ this.lastViewportW = this.viewportWidth
306
+ this.lastViewportH = this.viewportHeight
307
+ this.lastRectX = layoutRect.x
308
+ this.lastRectY = layoutRect.y
309
+ this.lastRectW = layoutRect.w
310
+ this.lastRectH = layoutRect.h
311
+ this.lastOffsetY = this.effectiveOffset
312
+ this.lastOffsetX = this.effectiveOffsetX
313
+
314
+ this.onScrollLayoutChange({
315
+ content: { width: this.contentWidth, height: this.contentHeight },
316
+ viewport: { width: this.viewportWidth, height: this.viewportHeight },
317
+ offset: { x: this.effectiveOffsetX, y: this.effectiveOffset },
318
+ rect: { x: layoutRect.x, y: layoutRect.y, w: layoutRect.w, h: layoutRect.h },
319
+ axis: this.axis,
320
+ })
321
+ }
218
322
  }
219
323
 
220
324
  render(buffer: CellBuffer, palette: Palette): void {
@@ -224,8 +328,8 @@ export class ScrollHost extends SingleChildHost {
224
328
  // Fill background (inherit from parent if not explicitly set)
225
329
  fillRectWithInheritedBg(buffer, palette, { x, y, w, h }, this.bg, this.parent)
226
330
 
227
- // Render children with clipping
228
- buffer.withClip(x, y, w, h, () => {
331
+ // Render children with clipping (exclude scrollbar gutters)
332
+ buffer.withClip(x, y, this.viewportWidth, this.viewportHeight, () => {
229
333
  const child = this.child
230
334
  if (child) {
231
335
  child.render(buffer, palette)
@@ -243,18 +347,18 @@ export class ScrollHost extends SingleChildHost {
243
347
  const { x, y, w, h } = this.rect
244
348
 
245
349
  // Vertical scrollbar
246
- if ((this.axis === "vertical" || this.axis === "both") && this.contentHeight > h) {
247
- const maxScroll = this.contentHeight - h
350
+ if (this.showVerticalScrollbar) {
351
+ const maxScroll = Math.max(1, this.contentHeight - this.viewportHeight)
248
352
  const scrollRatio = Math.min(1, this.effectiveOffset / maxScroll)
249
- const thumbRatio = h / this.contentHeight
250
- const thumbHeight = Math.max(1, Math.floor(h * thumbRatio))
251
- const thumbY = Math.floor((h - thumbHeight) * scrollRatio)
353
+ const thumbRatio = this.viewportHeight / this.contentHeight
354
+ const thumbHeight = Math.max(1, Math.floor(this.viewportHeight * thumbRatio))
355
+ const thumbY = Math.floor((this.viewportHeight - thumbHeight) * scrollRatio)
252
356
 
253
357
  const trackStyle = palette.id({ fg: 8 }) // dim
254
358
  const thumbStyle = palette.id({ fg: 7 }) // brighter
255
359
 
256
360
  // Draw track
257
- for (let row = 0; row < h; row++) {
361
+ for (let row = 0; row < this.viewportHeight; row++) {
258
362
  const char = row >= thumbY && row < thumbY + thumbHeight ? "┃" : "│"
259
363
  const style = row >= thumbY && row < thumbY + thumbHeight ? thumbStyle : trackStyle
260
364
  buffer.drawCP(x + w - 1, y + row, char.codePointAt(0)!, style)
@@ -262,18 +366,18 @@ export class ScrollHost extends SingleChildHost {
262
366
  }
263
367
 
264
368
  // Horizontal scrollbar
265
- if ((this.axis === "horizontal" || this.axis === "both") && this.contentWidth > w) {
266
- const maxScroll = this.contentWidth - w
369
+ if (this.showHorizontalScrollbar) {
370
+ const maxScroll = Math.max(1, this.contentWidth - this.viewportWidth)
267
371
  const scrollRatio = Math.min(1, this.effectiveOffsetX / maxScroll)
268
- const thumbRatio = w / this.contentWidth
269
- const thumbWidth = Math.max(1, Math.floor(w * thumbRatio))
270
- const thumbX = Math.floor((w - thumbWidth) * scrollRatio)
372
+ const thumbRatio = this.viewportWidth / this.contentWidth
373
+ const thumbWidth = Math.max(1, Math.floor(this.viewportWidth * thumbRatio))
374
+ const thumbX = Math.floor((this.viewportWidth - thumbWidth) * scrollRatio)
271
375
 
272
376
  const trackStyle = palette.id({ fg: 8 })
273
377
  const thumbStyle = palette.id({ fg: 7 })
274
378
 
275
379
  // Draw track
276
- for (let col = 0; col < w; col++) {
380
+ for (let col = 0; col < this.viewportWidth; col++) {
277
381
  const char = col >= thumbX && col < thumbX + thumbWidth ? "━" : "─"
278
382
  const style = col >= thumbX && col < thumbX + thumbWidth ? thumbStyle : trackStyle
279
383
  buffer.drawCP(x + col, y + h - 1, char.codePointAt(0)!, style)
@@ -285,17 +389,35 @@ export class ScrollHost extends SingleChildHost {
285
389
  super.updateProps(props)
286
390
  // Scroll is greedy by default unless explicitly set to false
287
391
  this.applyGreedyDefault(props, 1)
392
+ const prevAxis = this.axis
393
+ const prevOffsetY = this.offsetY
394
+ const prevOffsetX = this.offsetX
395
+ const prevAlign = this.align
396
+ const prevBg = this.bg
397
+ const prevShowScrollbar = this.showScrollbar
398
+ const prevSticky = this.sticky
399
+
288
400
  if (props.axis !== undefined) this.axis = (props.axis as ScrollProps["axis"]) ?? "vertical"
289
- if (props.offset !== undefined) this.offset = props.offset as number
401
+ if (props.offsetY !== undefined) this.offsetY = props.offsetY as number
290
402
  if (props.offsetX !== undefined) this.offsetX = props.offsetX as number
291
- if (props.align !== undefined) this.align = (props.align as ScrollProps["align"]) ?? "start"
403
+ if (props.align !== undefined) this.align = props.align as ScrollProps["align"]
292
404
  this.bg = props.bg as Color | undefined
293
405
  if (props.showScrollbar !== undefined) this.showScrollbar = props.showScrollbar as boolean
294
406
  if (props.sticky !== undefined) this.sticky = props.sticky as boolean
295
- this.onContentSize = props.onContentSize as ScrollProps["onContentSize"]
296
- this.onViewportSize = props.onViewportSize as ScrollProps["onViewportSize"]
297
- this.onEffectiveOffset = props.onEffectiveOffset as ScrollProps["onEffectiveOffset"]
298
- this.onEffectiveOffsetX = props.onEffectiveOffsetX as ScrollProps["onEffectiveOffsetX"]
299
- this.onRect = props.onRect as ScrollProps["onRect"]
407
+ this.onScrollLayoutChange = props.onScrollLayoutChange as ScrollProps["onScrollLayoutChange"]
408
+
409
+ const layoutChanged =
410
+ prevAxis !== this.axis ||
411
+ prevOffsetY !== this.offsetY ||
412
+ prevOffsetX !== this.offsetX ||
413
+ prevSticky !== this.sticky ||
414
+ prevAlign !== this.align ||
415
+ prevShowScrollbar !== this.showScrollbar
416
+
417
+ if (layoutChanged) {
418
+ this.invalidateLayout()
419
+ } else if (prevBg !== this.bg) {
420
+ this.invalidateRender()
421
+ }
300
422
  }
301
423
  }
@@ -26,6 +26,7 @@ export abstract class SingleChildHost extends BaseHost {
26
26
  this.children.splice(0, 1)
27
27
  }
28
28
  child.parent = null
29
+ this.invalidateLayout()
29
30
  }
30
31
 
31
32
  private setSingleChild(child: HostInstance): void {
@@ -44,5 +45,6 @@ export abstract class SingleChildHost extends BaseHost {
44
45
  }
45
46
 
46
47
  child.parent = this
48
+ this.invalidateLayout()
47
49
  }
48
50
  }
@@ -1,6 +1,6 @@
1
1
  import type { CellBuffer, Palette } from "@effect-tui/core"
2
2
  import type { CommonProps, HostContext, Size } from "../reconciler/types.js"
3
- import { BaseHost } from "./base.js"
3
+ import { LeafHost } from "./leaf.js"
4
4
 
5
5
  export interface SpacerProps extends CommonProps {
6
6
  /** Minimum width (default 0) */
@@ -9,7 +9,7 @@ export interface SpacerProps extends CommonProps {
9
9
  minHeight?: number
10
10
  }
11
11
 
12
- export class SpacerHost extends BaseHost {
12
+ export class SpacerHost extends LeafHost {
13
13
  minWidth = 0
14
14
  minHeight = 0
15
15
 
@@ -21,7 +21,7 @@ export class SpacerHost extends BaseHost {
21
21
  this.updateProps(props as unknown as Record<string, unknown>)
22
22
  }
23
23
 
24
- measure(_maxW: number, _maxH: number): Size {
24
+ protected measureSelf(_maxW: number, _maxH: number): Size {
25
25
  // Spacers have no natural size, they expand via greedy
26
26
  return { w: this.minWidth, h: this.minHeight }
27
27
  }
@@ -34,7 +34,12 @@ export class SpacerHost extends BaseHost {
34
34
  super.updateProps(props)
35
35
  // Spacer is greedy by default unless explicitly set to false
36
36
  this.applyGreedyDefault(props, 1)
37
+ const prevMinWidth = this.minWidth
38
+ const prevMinHeight = this.minHeight
37
39
  if (props.minWidth !== undefined) this.minWidth = props.minWidth as number
38
40
  if (props.minHeight !== undefined) this.minHeight = props.minHeight as number
41
+ if (prevMinWidth !== this.minWidth || prevMinHeight !== this.minHeight) {
42
+ this.invalidateLayout()
43
+ }
39
44
  }
40
45
  }