@effect-tui/react 0.15.2 → 0.16.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 (249) hide show
  1. package/README.md +2 -2
  2. package/dist/src/components/ListView.d.ts +4 -4
  3. package/dist/src/components/ListView.d.ts.map +1 -1
  4. package/dist/src/components/ListView.js +16 -17
  5. package/dist/src/components/ListView.js.map +1 -1
  6. package/dist/src/console/ConsolePopover.d.ts +7 -1
  7. package/dist/src/console/ConsolePopover.d.ts.map +1 -1
  8. package/dist/src/console/ConsolePopover.js +55 -74
  9. package/dist/src/console/ConsolePopover.js.map +1 -1
  10. package/dist/src/debug/DebugOverlay.d.ts.map +1 -1
  11. package/dist/src/debug/DebugOverlay.js +3 -57
  12. package/dist/src/debug/DebugOverlay.js.map +1 -1
  13. package/dist/src/debug/DiagnosticsPanel.js +1 -1
  14. package/dist/src/debug/DiagnosticsPanel.js.map +1 -1
  15. package/dist/src/dev.d.ts +5 -117
  16. package/dist/src/dev.d.ts.map +1 -1
  17. package/dist/src/dev.js +3 -333
  18. package/dist/src/dev.js.map +1 -1
  19. package/dist/src/hooks/use-scroll.d.ts +31 -35
  20. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  21. package/dist/src/hooks/use-scroll.js +51 -90
  22. package/dist/src/hooks/use-scroll.js.map +1 -1
  23. package/dist/src/hosts/canvas.d.ts +2 -2
  24. package/dist/src/hosts/canvas.d.ts.map +1 -1
  25. package/dist/src/hosts/canvas.js +8 -10
  26. package/dist/src/hosts/canvas.js.map +1 -1
  27. package/dist/src/hosts/codeblock.d.ts +2 -2
  28. package/dist/src/hosts/codeblock.js +2 -2
  29. package/dist/src/hosts/flex-container.d.ts +1 -1
  30. package/dist/src/hosts/flex-container.d.ts.map +1 -1
  31. package/dist/src/hosts/flex-container.js +3 -3
  32. package/dist/src/hosts/flex-container.js.map +1 -1
  33. package/dist/src/hosts/index.d.ts +2 -1
  34. package/dist/src/hosts/index.d.ts.map +1 -1
  35. package/dist/src/hosts/index.js +2 -1
  36. package/dist/src/hosts/index.js.map +1 -1
  37. package/dist/src/hosts/layout-helpers.d.ts +10 -0
  38. package/dist/src/hosts/layout-helpers.d.ts.map +1 -0
  39. package/dist/src/hosts/layout-helpers.js +10 -0
  40. package/dist/src/hosts/layout-helpers.js.map +1 -0
  41. package/dist/src/hosts/leaf.d.ts +14 -0
  42. package/dist/src/hosts/leaf.d.ts.map +1 -0
  43. package/dist/src/hosts/leaf.js +31 -0
  44. package/dist/src/hosts/leaf.js.map +1 -0
  45. package/dist/src/hosts/overlay.d.ts.map +1 -1
  46. package/dist/src/hosts/overlay.js +4 -7
  47. package/dist/src/hosts/overlay.js.map +1 -1
  48. package/dist/src/hosts/scroll.d.ts +47 -24
  49. package/dist/src/hosts/scroll.d.ts.map +1 -1
  50. package/dist/src/hosts/scroll.js +68 -51
  51. package/dist/src/hosts/scroll.js.map +1 -1
  52. package/dist/src/hosts/spacer.d.ts +2 -2
  53. package/dist/src/hosts/spacer.js +2 -2
  54. package/dist/src/hosts/text.d.ts +2 -3
  55. package/dist/src/hosts/text.d.ts.map +1 -1
  56. package/dist/src/hosts/text.js +5 -61
  57. package/dist/src/hosts/text.js.map +1 -1
  58. package/dist/src/hosts/vstack.js +1 -1
  59. package/dist/src/hosts/vstack.js.map +1 -1
  60. package/dist/src/hosts/zstack.d.ts +1 -1
  61. package/dist/src/hosts/zstack.d.ts.map +1 -1
  62. package/dist/src/hosts/zstack.js +6 -6
  63. package/dist/src/hosts/zstack.js.map +1 -1
  64. package/dist/src/index.d.ts +1 -1
  65. package/dist/src/index.d.ts.map +1 -1
  66. package/dist/src/internal/dev/hmr.d.ts +20 -0
  67. package/dist/src/internal/dev/hmr.d.ts.map +1 -0
  68. package/dist/src/internal/dev/hmr.js +93 -0
  69. package/dist/src/internal/dev/hmr.js.map +1 -0
  70. package/dist/src/internal/dev/runtime.d.ts +24 -0
  71. package/dist/src/internal/dev/runtime.d.ts.map +1 -0
  72. package/dist/src/internal/dev/runtime.js +135 -0
  73. package/dist/src/internal/dev/runtime.js.map +1 -0
  74. package/dist/src/internal/dev/ui.d.ts +13 -0
  75. package/dist/src/internal/dev/ui.d.ts.map +1 -0
  76. package/dist/src/internal/dev/ui.js +51 -0
  77. package/dist/src/internal/dev/ui.js.map +1 -0
  78. package/dist/src/internal/renderer/context.d.ts +9 -0
  79. package/dist/src/internal/renderer/context.d.ts.map +1 -0
  80. package/dist/src/internal/renderer/context.js +22 -0
  81. package/dist/src/internal/renderer/context.js.map +1 -0
  82. package/dist/src/internal/renderer/core/FrameBuilder.d.ts +18 -0
  83. package/dist/src/internal/renderer/core/FrameBuilder.d.ts.map +1 -0
  84. package/dist/src/internal/renderer/core/FrameBuilder.js +40 -0
  85. package/dist/src/internal/renderer/core/FrameBuilder.js.map +1 -0
  86. package/dist/src/internal/renderer/core/RendererState.d.ts +41 -0
  87. package/dist/src/internal/renderer/core/RendererState.d.ts.map +1 -0
  88. package/dist/src/internal/renderer/core/RendererState.js +70 -0
  89. package/dist/src/internal/renderer/core/RendererState.js.map +1 -0
  90. package/dist/src/internal/renderer/core/index.d.ts +3 -0
  91. package/dist/src/internal/renderer/core/index.d.ts.map +1 -0
  92. package/dist/src/internal/renderer/core/index.js +3 -0
  93. package/dist/src/internal/renderer/core/index.js.map +1 -0
  94. package/dist/src/internal/renderer/index.d.ts +40 -0
  95. package/dist/src/internal/renderer/index.d.ts.map +1 -0
  96. package/dist/src/internal/renderer/index.js +518 -0
  97. package/dist/src/internal/renderer/index.js.map +1 -0
  98. package/dist/src/internal/renderer/input/InputProcessor.d.ts +30 -0
  99. package/dist/src/internal/renderer/input/InputProcessor.d.ts.map +1 -0
  100. package/dist/src/internal/renderer/input/InputProcessor.js +122 -0
  101. package/dist/src/internal/renderer/input/InputProcessor.js.map +1 -0
  102. package/dist/src/internal/renderer/input/index.d.ts +2 -0
  103. package/dist/src/internal/renderer/input/index.d.ts.map +1 -0
  104. package/dist/src/internal/renderer/input/index.js +2 -0
  105. package/dist/src/internal/renderer/input/index.js.map +1 -0
  106. package/dist/src/internal/renderer/lifecycle/EventBus.d.ts +42 -0
  107. package/dist/src/internal/renderer/lifecycle/EventBus.d.ts.map +1 -0
  108. package/dist/src/internal/renderer/lifecycle/EventBus.js +97 -0
  109. package/dist/src/internal/renderer/lifecycle/EventBus.js.map +1 -0
  110. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.d.ts +13 -0
  111. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.d.ts.map +1 -0
  112. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.js +111 -0
  113. package/dist/src/internal/renderer/lifecycle/ProcessLifecycle.js.map +1 -0
  114. package/dist/src/internal/renderer/lifecycle/RenderCache.d.ts +3 -0
  115. package/dist/src/internal/renderer/lifecycle/RenderCache.d.ts.map +1 -0
  116. package/dist/src/internal/renderer/lifecycle/RenderCache.js +9 -0
  117. package/dist/src/internal/renderer/lifecycle/RenderCache.js.map +1 -0
  118. package/dist/src/internal/renderer/lifecycle/index.d.ts +4 -0
  119. package/dist/src/internal/renderer/lifecycle/index.d.ts.map +1 -0
  120. package/dist/src/internal/renderer/lifecycle/index.js +4 -0
  121. package/dist/src/internal/renderer/lifecycle/index.js.map +1 -0
  122. package/dist/src/internal/renderer/modes/FullscreenRenderer.d.ts +12 -0
  123. package/dist/src/internal/renderer/modes/FullscreenRenderer.d.ts.map +1 -0
  124. package/dist/src/internal/renderer/modes/FullscreenRenderer.js +54 -0
  125. package/dist/src/internal/renderer/modes/FullscreenRenderer.js.map +1 -0
  126. package/dist/src/internal/renderer/modes/InlineRenderer.d.ts +25 -0
  127. package/dist/src/internal/renderer/modes/InlineRenderer.d.ts.map +1 -0
  128. package/dist/src/internal/renderer/modes/InlineRenderer.js +166 -0
  129. package/dist/src/internal/renderer/modes/InlineRenderer.js.map +1 -0
  130. package/dist/src/internal/renderer/modes/RendererMode.d.ts +42 -0
  131. package/dist/src/internal/renderer/modes/RendererMode.d.ts.map +1 -0
  132. package/dist/src/internal/renderer/modes/RendererMode.js +2 -0
  133. package/dist/src/internal/renderer/modes/RendererMode.js.map +1 -0
  134. package/dist/src/internal/renderer/modes/StaticContentRenderer.d.ts +25 -0
  135. package/dist/src/internal/renderer/modes/StaticContentRenderer.d.ts.map +1 -0
  136. package/dist/src/internal/renderer/modes/StaticContentRenderer.js +49 -0
  137. package/dist/src/internal/renderer/modes/StaticContentRenderer.js.map +1 -0
  138. package/dist/src/internal/renderer/modes/index.d.ts +5 -0
  139. package/dist/src/internal/renderer/modes/index.d.ts.map +1 -0
  140. package/dist/src/internal/renderer/modes/index.js +4 -0
  141. package/dist/src/internal/renderer/modes/index.js.map +1 -0
  142. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.d.ts +13 -0
  143. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.d.ts.map +1 -0
  144. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.js +75 -0
  145. package/dist/src/internal/renderer/terminal/KeyboardCapabilityProbe.js.map +1 -0
  146. package/dist/src/internal/renderer/terminal/TerminalSetup.d.ts +29 -0
  147. package/dist/src/internal/renderer/terminal/TerminalSetup.d.ts.map +1 -0
  148. package/dist/src/internal/renderer/terminal/TerminalSetup.js +82 -0
  149. package/dist/src/internal/renderer/terminal/TerminalSetup.js.map +1 -0
  150. package/dist/src/internal/renderer/terminal/index.d.ts +3 -0
  151. package/dist/src/internal/renderer/terminal/index.d.ts.map +1 -0
  152. package/dist/src/internal/renderer/terminal/index.js +3 -0
  153. package/dist/src/internal/renderer/terminal/index.js.map +1 -0
  154. package/dist/src/internal/renderer/types.d.ts +118 -0
  155. package/dist/src/internal/renderer/types.d.ts.map +1 -0
  156. package/dist/src/internal/renderer/types.js +2 -0
  157. package/dist/src/internal/renderer/types.js.map +1 -0
  158. package/dist/src/renderer-context.d.ts +1 -8
  159. package/dist/src/renderer-context.d.ts.map +1 -1
  160. package/dist/src/renderer-context.js +1 -21
  161. package/dist/src/renderer-context.js.map +1 -1
  162. package/dist/src/renderer-types.d.ts +1 -115
  163. package/dist/src/renderer-types.d.ts.map +1 -1
  164. package/dist/src/renderer.d.ts +1 -31
  165. package/dist/src/renderer.d.ts.map +1 -1
  166. package/dist/src/renderer.js +1 -495
  167. package/dist/src/renderer.js.map +1 -1
  168. package/dist/src/test/render-tui.d.ts +3 -3
  169. package/dist/src/test/render-tui.d.ts.map +1 -1
  170. package/dist/src/test/render-tui.js +16 -9
  171. package/dist/src/test/render-tui.js.map +1 -1
  172. package/dist/src/utils/alignment.d.ts +1 -1
  173. package/dist/src/utils/alignment.d.ts.map +1 -1
  174. package/dist/src/utils/alignment.js +0 -2
  175. package/dist/src/utils/alignment.js.map +1 -1
  176. package/dist/src/utils/console-helpers.d.ts +19 -0
  177. package/dist/src/utils/console-helpers.d.ts.map +1 -0
  178. package/dist/src/utils/console-helpers.js +61 -0
  179. package/dist/src/utils/console-helpers.js.map +1 -0
  180. package/dist/src/utils/index.d.ts +1 -1
  181. package/dist/src/utils/index.d.ts.map +1 -1
  182. package/dist/src/utils/index.js +1 -1
  183. package/dist/src/utils/index.js.map +1 -1
  184. package/dist/src/utils/styles.d.ts +8 -1
  185. package/dist/src/utils/styles.d.ts.map +1 -1
  186. package/dist/src/utils/styles.js +10 -8
  187. package/dist/src/utils/styles.js.map +1 -1
  188. package/dist/src/utils/text-wrap.d.ts +5 -0
  189. package/dist/src/utils/text-wrap.d.ts.map +1 -1
  190. package/dist/src/utils/text-wrap.js +110 -48
  191. package/dist/src/utils/text-wrap.js.map +1 -1
  192. package/dist/src/visualize/index.js +1 -1
  193. package/dist/src/visualize/index.js.map +1 -1
  194. package/dist/tsconfig.tsbuildinfo +1 -1
  195. package/package.json +2 -2
  196. package/src/components/ListView.tsx +21 -23
  197. package/src/console/ConsolePopover.tsx +124 -107
  198. package/src/debug/DebugOverlay.ts +15 -74
  199. package/src/debug/DiagnosticsPanel.tsx +1 -1
  200. package/src/dev.tsx +5 -458
  201. package/src/hooks/use-scroll.ts +85 -145
  202. package/src/hosts/canvas.ts +8 -11
  203. package/src/hosts/codeblock.ts +2 -2
  204. package/src/hosts/flex-container.ts +4 -4
  205. package/src/hosts/index.ts +10 -1
  206. package/src/hosts/layout-helpers.ts +20 -0
  207. package/src/hosts/leaf.ts +36 -0
  208. package/src/hosts/overlay.ts +11 -9
  209. package/src/hosts/scroll.ts +94 -69
  210. package/src/hosts/spacer.ts +2 -2
  211. package/src/hosts/text.ts +5 -58
  212. package/src/hosts/vstack.ts +1 -1
  213. package/src/hosts/zstack.ts +7 -7
  214. package/src/index.ts +1 -1
  215. package/src/internal/dev/hmr.ts +101 -0
  216. package/src/internal/dev/runtime.ts +170 -0
  217. package/src/internal/dev/ui.tsx +87 -0
  218. package/src/internal/renderer/context.ts +27 -0
  219. package/src/{renderer → internal/renderer}/core/FrameBuilder.ts +2 -2
  220. package/src/internal/renderer/index.ts +656 -0
  221. package/src/{renderer → internal/renderer}/input/InputProcessor.ts +10 -1
  222. package/src/{renderer → internal/renderer}/lifecycle/EventBus.ts +9 -1
  223. package/src/internal/renderer/lifecycle/ProcessLifecycle.ts +125 -0
  224. package/src/internal/renderer/lifecycle/index.ts +3 -0
  225. package/src/{renderer → internal/renderer}/modes/InlineRenderer.ts +5 -2
  226. package/src/{renderer → internal/renderer}/modes/RendererMode.ts +1 -1
  227. package/src/{renderer → internal/renderer}/modes/StaticContentRenderer.ts +5 -2
  228. package/src/internal/renderer/terminal/KeyboardCapabilityProbe.ts +91 -0
  229. package/src/{renderer/lifecycle → internal/renderer/terminal}/TerminalSetup.ts +4 -22
  230. package/src/internal/renderer/terminal/index.ts +2 -0
  231. package/src/internal/renderer/types.ts +125 -0
  232. package/src/renderer-context.ts +1 -27
  233. package/src/renderer-types.ts +10 -123
  234. package/src/renderer.ts +1 -619
  235. package/src/test/render-tui.ts +16 -10
  236. package/src/utils/alignment.ts +1 -3
  237. package/src/utils/console-helpers.ts +86 -0
  238. package/src/utils/index.ts +1 -1
  239. package/src/utils/styles.ts +16 -4
  240. package/src/utils/text-wrap.ts +139 -48
  241. package/src/visualize/index.tsx +1 -1
  242. package/src/renderer/lifecycle/ResizeManager.ts +0 -65
  243. package/src/renderer/lifecycle/index.ts +0 -4
  244. /package/src/{renderer → internal/renderer}/core/RendererState.ts +0 -0
  245. /package/src/{renderer → internal/renderer}/core/index.ts +0 -0
  246. /package/src/{renderer → internal/renderer}/input/index.ts +0 -0
  247. /package/src/{renderer → internal/renderer}/lifecycle/RenderCache.ts +0 -0
  248. /package/src/{renderer → internal/renderer}/modes/FullscreenRenderer.ts +0 -0
  249. /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,34 +39,43 @@ 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
@@ -63,6 +89,8 @@ export class ScrollHost extends SingleChildHost {
63
89
  private lastRectY = -1
64
90
  private lastRectW = -1
65
91
  private lastRectH = -1
92
+ private lastOffsetY = -1
93
+ private lastOffsetX = -1
66
94
  // Track if we were at end (for sticky behavior)
67
95
  private wasAtEnd = true
68
96
  private wasAtEndX = true
@@ -96,7 +124,7 @@ export class ScrollHost extends SingleChildHost {
96
124
  const childSize = child.measure(childMaxW, childMaxH)
97
125
  this.contentWidth = childSize.w
98
126
  this.contentHeight = childSize.h
99
- // Note: onContentSize callback is deferred to layout() to keep measure() pure
127
+ // Note: onScrollLayoutChange callback is deferred to layout() to keep measure() pure
100
128
  }
101
129
 
102
130
  // Report natural content size (clamped to constrained bounds)
@@ -109,34 +137,16 @@ export class ScrollHost extends SingleChildHost {
109
137
 
110
138
  override layout(rect: Rect): void {
111
139
  const layoutRect = this.layoutWithConstraints(rect)
140
+ const { x: alignX, y: alignY } = resolveAlign(this.align)
112
141
 
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 (
142
+ const contentChanged =
143
+ this.contentWidth !== this.lastReportedContentW || this.contentHeight !== this.lastReportedContentH
144
+ const viewportChanged = layoutRect.w !== this.lastViewportW || layoutRect.h !== this.lastViewportH
145
+ const rectChanged =
129
146
  layoutRect.x !== this.lastRectX ||
130
147
  layoutRect.y !== this.lastRectY ||
131
148
  layoutRect.w !== this.lastRectW ||
132
149
  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
150
 
141
151
  const child = this.child
142
152
  if (!child) return
@@ -146,7 +156,7 @@ export class ScrollHost extends SingleChildHost {
146
156
  const maxScrollX = Math.max(0, this.contentWidth - layoutRect.w)
147
157
 
148
158
  // Start with the offset from props (controlled by useScroll)
149
- let scrollY = this.offset
159
+ let scrollY = this.offsetY
150
160
  let scrollX = this.offsetX
151
161
 
152
162
  // Sticky scroll logic (vertical):
@@ -154,7 +164,7 @@ export class ScrollHost extends SingleChildHost {
154
164
  // - If at end (or was at end and content grew), stay stuck
155
165
  if (this.sticky && (this.axis === "vertical" || this.axis === "both")) {
156
166
  // Detect if user scrolled away (offset prop is less than where we rendered)
157
- const userScrolledAway = this.offset < this.effectiveOffset - 1
167
+ const userScrolledAway = this.offsetY < this.effectiveOffset - 1
158
168
 
159
169
  if (userScrolledAway) {
160
170
  // User scrolled up - unstick
@@ -185,25 +195,23 @@ export class ScrollHost extends SingleChildHost {
185
195
  scrollX = Math.max(0, Math.min(maxScrollX, scrollX))
186
196
 
187
197
  // 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
198
  this.effectiveOffset = scrollY
196
199
  this.effectiveOffsetX = scrollX
200
+ const offsetChanged = this.effectiveOffset !== this.lastOffsetY || this.effectiveOffsetX !== this.lastOffsetX
197
201
 
198
202
  // 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
203
+ if (this.contentHeight < layoutRect.h && (this.axis === "vertical" || this.axis === "both")) {
204
+ if (alignY === "bottom") {
202
205
  scrollY = -(layoutRect.h - this.contentHeight)
206
+ } else if (alignY === "center") {
207
+ scrollY = -Math.floor((layoutRect.h - this.contentHeight) / 2)
203
208
  }
204
- if (this.contentWidth < layoutRect.w && (this.axis === "horizontal" || this.axis === "both")) {
205
- // Align to right
209
+ }
210
+ if (this.contentWidth < layoutRect.w && (this.axis === "horizontal" || this.axis === "both")) {
211
+ if (alignX === "right") {
206
212
  scrollX = -(layoutRect.w - this.contentWidth)
213
+ } else if (alignX === "center") {
214
+ scrollX = -Math.floor((layoutRect.w - this.contentWidth) / 2)
207
215
  }
208
216
  }
209
217
 
@@ -215,6 +223,27 @@ export class ScrollHost extends SingleChildHost {
215
223
  h: this.contentHeight,
216
224
  }
217
225
  child.layout(childRect)
226
+
227
+ if (this.onScrollLayoutChange && (contentChanged || viewportChanged || rectChanged || offsetChanged)) {
228
+ this.lastReportedContentW = this.contentWidth
229
+ this.lastReportedContentH = this.contentHeight
230
+ this.lastViewportW = layoutRect.w
231
+ this.lastViewportH = layoutRect.h
232
+ this.lastRectX = layoutRect.x
233
+ this.lastRectY = layoutRect.y
234
+ this.lastRectW = layoutRect.w
235
+ this.lastRectH = layoutRect.h
236
+ this.lastOffsetY = this.effectiveOffset
237
+ this.lastOffsetX = this.effectiveOffsetX
238
+
239
+ this.onScrollLayoutChange({
240
+ content: { width: this.contentWidth, height: this.contentHeight },
241
+ viewport: { width: layoutRect.w, height: layoutRect.h },
242
+ offset: { x: this.effectiveOffsetX, y: this.effectiveOffset },
243
+ rect: { x: layoutRect.x, y: layoutRect.y, w: layoutRect.w, h: layoutRect.h },
244
+ axis: this.axis,
245
+ })
246
+ }
218
247
  }
219
248
 
220
249
  render(buffer: CellBuffer, palette: Palette): void {
@@ -286,16 +315,12 @@ export class ScrollHost extends SingleChildHost {
286
315
  // Scroll is greedy by default unless explicitly set to false
287
316
  this.applyGreedyDefault(props, 1)
288
317
  if (props.axis !== undefined) this.axis = (props.axis as ScrollProps["axis"]) ?? "vertical"
289
- if (props.offset !== undefined) this.offset = props.offset as number
318
+ if (props.offsetY !== undefined) this.offsetY = props.offsetY as number
290
319
  if (props.offsetX !== undefined) this.offsetX = props.offsetX as number
291
- if (props.align !== undefined) this.align = (props.align as ScrollProps["align"]) ?? "start"
320
+ if (props.align !== undefined) this.align = props.align as ScrollProps["align"]
292
321
  this.bg = props.bg as Color | undefined
293
322
  if (props.showScrollbar !== undefined) this.showScrollbar = props.showScrollbar as boolean
294
323
  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"]
324
+ this.onScrollLayoutChange = props.onScrollLayoutChange as ScrollProps["onScrollLayoutChange"]
300
325
  }
301
326
  }
@@ -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
 
package/src/hosts/text.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { type CellBuffer, type Color, displayWidth, type Palette } from "@effect-tui/core"
2
2
  import type { ColorMotionValue } from "../motion/color-motion-value.js"
3
3
  import type { CommonProps, HostContext, HostInstance, Rect, Size } from "../reconciler/types.js"
4
- import { isWhitespace, resolveInheritedBgStyle, splitWords, styleIdFromProps, wrapSpans } from "../utils/index.js"
4
+ import { resolveInheritedBgStyle, styleIdFromProps, wrapSpans, wrapText } from "../utils/index.js"
5
5
  import { BaseHost, getInheritedBg } from "./base.js"
6
+ import { LeafHost } from "./leaf.js"
6
7
 
7
8
  /** Color prop that can be a static Color or a spring-animated ColorMotionValue */
8
9
  export type ColorProp = Color | ColorMotionValue
@@ -205,7 +206,7 @@ export class TextHost extends BaseHost {
205
206
  if (this.wrap) {
206
207
  // Wrap mode: may span multiple lines. Cache result for render()
207
208
  this.cachedLines = rawLines.flatMap((line, idx) =>
208
- idx < rawLines.length - 1 ? [...this.wrapText(line, constrained.w), ""] : this.wrapText(line, constrained.w),
209
+ idx < rawLines.length - 1 ? [...wrapText(line, constrained.w), ""] : wrapText(line, constrained.w),
209
210
  )
210
211
  this.cachedWidth = constrained.w
211
212
  const w = this.cachedLines.reduce((max, line) => Math.max(max, displayWidth(line)), 0)
@@ -220,60 +221,6 @@ export class TextHost extends BaseHost {
220
221
  return this.constrainResult({ w, h })
221
222
  }
222
223
 
223
- /** Wrap text to fit within maxWidth, preferring word boundaries */
224
- private wrapText(text: string, maxWidth: number): string[] {
225
- const result: string[] = []
226
- for (const rawLine of text.split("\n")) {
227
- if (rawLine === "") {
228
- result.push("")
229
- continue
230
- }
231
-
232
- // Split into words (keeping whitespace as separate tokens)
233
- const tokens = splitWords(rawLine)
234
- let line = ""
235
- let lineW = 0
236
-
237
- for (const token of tokens) {
238
- const tokenW = displayWidth(token)
239
- const isWs = isWhitespace(token)
240
-
241
- if (lineW + tokenW <= maxWidth) {
242
- // Token fits on current line
243
- line += token
244
- lineW += tokenW
245
- } else if (isWs) {
246
- // Whitespace doesn't fit - just skip it (don't start new line with space)
247
- continue
248
- } else if (tokenW <= maxWidth) {
249
- // Word doesn't fit but is smaller than maxWidth - start new line
250
- if (line.trimEnd()) result.push(line.trimEnd())
251
- line = token
252
- lineW = tokenW
253
- } else {
254
- // Word is longer than maxWidth - break it character by character
255
- if (line.trimEnd()) result.push(line.trimEnd())
256
- line = ""
257
- lineW = 0
258
- for (const ch of token) {
259
- const chW = displayWidth(ch)
260
- if (lineW + chW > maxWidth && line.length > 0) {
261
- result.push(line)
262
- line = ch
263
- lineW = chW
264
- } else {
265
- line += ch
266
- lineW += chW
267
- }
268
- }
269
- }
270
- }
271
- // Preserve trailing space for inline flow - only trimEnd when breaking mid-line (done above)
272
- if (line) result.push(line)
273
- }
274
- return result.length > 0 ? result : [""]
275
- }
276
-
277
224
  override layout(rect: Rect): void {
278
225
  const layoutRect = this.layoutWithConstraints(rect)
279
226
  // Layout children (RawTextHost nodes) at same position
@@ -349,7 +296,7 @@ export class TextHost extends BaseHost {
349
296
  this.cachedLines && this.cachedWidth === rectW
350
297
  ? this.cachedLines
351
298
  : rawLines.flatMap((line, idx) =>
352
- idx < rawLines.length - 1 ? [...this.wrapText(line, rectW), ""] : this.wrapText(line, rectW),
299
+ idx < rawLines.length - 1 ? [...wrapText(line, rectW), ""] : wrapText(line, rectW),
353
300
  )
354
301
  const visibleLines = Math.min(lines.length, this.rect.h)
355
302
  // Explicitly paint background under text lines to clear stale styles (e.g., selection highlights).
@@ -399,7 +346,7 @@ export class TextHost extends BaseHost {
399
346
  }
400
347
 
401
348
  /** Special host for raw text nodes (React text children) */
402
- export class RawTextHost extends BaseHost {
349
+ export class RawTextHost extends LeafHost {
403
350
  content = ""
404
351
 
405
352
  constructor(text: string, ctx: HostContext) {
@@ -8,6 +8,6 @@ export interface VStackProps extends FlexContainerProps<"vertical"> {}
8
8
  */
9
9
  export class VStackHost extends FlexContainerHost<"vertical"> {
10
10
  constructor(props: VStackProps, ctx: HostContext) {
11
- super("vertical", "vstack", props as FlexContainerProps<"vertical">, ctx, "leading")
11
+ super("vertical", "vstack", props as FlexContainerProps<"vertical">, ctx, "left")
12
12
  }
13
13
  }
@@ -1,10 +1,11 @@
1
1
  import type { CellBuffer, Palette } from "@effect-tui/core"
2
2
  import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
3
- import { alignedChildRect, type HAlign, type VAlign } from "../utils/index.js"
3
+ import { type HAlign, type VAlign } from "../utils/index.js"
4
4
  import { BaseHost } from "./base.js"
5
+ import { layoutAlignedChildren } from "./layout-helpers.js"
5
6
 
6
7
  export interface ZStackProps extends CommonProps {
7
- alignment?: { h?: "leading" | "center" | "trailing"; v?: "top" | "center" | "bottom" }
8
+ alignment?: { h?: "left" | "center" | "right"; v?: "top" | "center" | "bottom" }
8
9
  }
9
10
 
10
11
  // Overlay children in the same rect, honoring alignment for each child.
@@ -45,11 +46,10 @@ export class ZStackHost extends BaseHost {
45
46
  override layout(rect: Rect): void {
46
47
  const layoutRect = this.layoutWithConstraints(rect)
47
48
 
48
- for (let i = 0; i < this.children.length; i++) {
49
- const child = this.children[i]
50
- const size = this.cachedSizes[i] ?? child.measure(layoutRect.w, layoutRect.h)
51
- child.layout(alignedChildRect(layoutRect, size, this.alignmentH, this.alignmentV))
52
- }
49
+ layoutAlignedChildren(layoutRect, this.children, this.cachedSizes, () => ({
50
+ h: this.alignmentH,
51
+ v: this.alignmentV,
52
+ }))
53
53
  }
54
54
 
55
55
  render(buffer: CellBuffer, palette: Palette): void {
package/src/index.ts CHANGED
@@ -67,7 +67,7 @@ export { useFrameStats } from "./hooks/useFrameStats.js"
67
67
  export type { BorderKind, BoxProps } from "./hosts/box.js"
68
68
  export type { CanvasProps, DrawContext } from "./hosts/canvas.js"
69
69
  export type { HStackProps } from "./hosts/hstack.js"
70
- export type { ScrollProps } from "./hosts/scroll.js"
70
+ export type { ScrollAlign, ScrollAlignX, ScrollAlignY, ScrollAxis, ScrollLayoutChange, ScrollProps } from "./hosts/scroll.js"
71
71
  export type { SpacerProps } from "./hosts/spacer.js"
72
72
  export type { SpanProps, SpanStyle, TextProps } from "./hosts/text.js"
73
73
  export type { VStackProps } from "./hosts/vstack.js"
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { globalValue } from "effect/GlobalValue"
3
+
4
+ /**
5
+ * Pipeable combinator that makes an atom persist across hot reloads.
6
+ *
7
+ * This is the cleanest way to define HMR-persistent atoms - just pipe it!
8
+ * Combines globalValue (for persistence) + keepAlive (prevents registry cleanup).
9
+ */
10
+ export function hmr(key: string): <A,>(self: A) => A {
11
+ return <A,>(self: A): A => {
12
+ return globalValue(Symbol.for(`hmr/${key}`), () => {
13
+ // Apply keepAlive if it's an atom (just sets keepAlive: true)
14
+ if (typeof self === "object" && self !== null && "keepAlive" in self) {
15
+ return Object.assign(Object.create(Object.getPrototypeOf(self)), {
16
+ ...self,
17
+ keepAlive: true,
18
+ })
19
+ }
20
+ return self
21
+ }) as A
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Wrap any value creation in HMR persistence.
27
+ */
28
+ export function hmrState<T>(key: string, create: () => T): T {
29
+ return globalValue(Symbol.for(`hmr/${key}`), create)
30
+ }
31
+
32
+ // Cache for runtime key extraction
33
+ const keyCache = new Map<string, string>()
34
+
35
+ /**
36
+ * Parse stack trace to get file path and line number.
37
+ * Returns null if parsing fails.
38
+ */
39
+ function parseStack(stack: string): { file: string; line: number; col: number } | null {
40
+ // Bun stack format: " at functionName (file:line:col)" or " at file:line:col"
41
+ const lines = stack.split("\n")
42
+ // Skip first line (Error message) and find caller (skip autoHmr itself)
43
+ for (let i = 2; i < lines.length; i++) {
44
+ const match = lines[i].match(/\((.+?):(\d+):(\d+)\)/) || lines[i].match(/at\s+(.+?):(\d+):(\d+)/)
45
+ if (match) {
46
+ return { file: match[1], line: parseInt(match[2], 10), col: parseInt(match[3], 10) }
47
+ }
48
+ }
49
+ return null
50
+ }
51
+
52
+ /**
53
+ * Extract variable name from source line using regex.
54
+ * Returns null if extraction fails.
55
+ */
56
+ function extractVarName(source: string, line: number): string | null {
57
+ const lines = source.split("\n")
58
+ if (line < 1 || line > lines.length) return null
59
+
60
+ const sourceLine = lines[line - 1]
61
+ const match = sourceLine.match(/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=/)
62
+ return match?.[1] ?? null
63
+ }
64
+
65
+ /**
66
+ * Auto-keyed HMR persistence for atoms.
67
+ *
68
+ * When used with the HMR plugin (recommended), keys are injected at load time
69
+ * based on the variable name. Without the plugin, falls back to runtime
70
+ * stack trace parsing.
71
+ */
72
+ export function autoHmr<A>(self: A): A {
73
+ // Get stack trace for caller location
74
+ const stack = new Error().stack ?? ""
75
+ const location = parseStack(stack)
76
+
77
+ if (!location) {
78
+ // Fallback: use a hash of the stack trace
79
+ const fallbackKey = `unknown:${stack.slice(0, 100)}`
80
+ return hmr(fallbackKey)(self)
81
+ }
82
+
83
+ const cacheKey = `${location.file}:${location.line}:${location.col}`
84
+
85
+ // Check cache first
86
+ let key = keyCache.get(cacheKey)
87
+ if (!key) {
88
+ try {
89
+ // Read source file and extract variable name
90
+ const source = readFileSync(location.file, "utf-8")
91
+ const varName = extractVarName(source, location.line)
92
+ key = `${location.file}:${varName ?? `line${location.line}`}`
93
+ } catch {
94
+ // File read failed, use line-based key
95
+ key = `${location.file}:line${location.line}`
96
+ }
97
+ keyCache.set(cacheKey, key)
98
+ }
99
+
100
+ return hmr(key)(self)
101
+ }