@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
package/src/hosts/text.ts CHANGED
@@ -1,8 +1,16 @@
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 {
5
+ resolveInheritedBgStyle,
6
+ splitSpansByNewline,
7
+ spansDisplayWidth,
8
+ styleIdFromProps,
9
+ wrapSpansByLine,
10
+ wrapText,
11
+ } from "../utils/index.js"
5
12
  import { BaseHost, getInheritedBg } from "./base.js"
13
+ import { LeafHost } from "./leaf.js"
6
14
 
7
15
  /** Color prop that can be a static Color or a spring-animated ColorMotionValue */
8
16
  export type ColorProp = Color | ColorMotionValue
@@ -49,6 +57,7 @@ export class TextHost extends BaseHost {
49
57
  // Cache wrapped lines between measure() and render()
50
58
  private cachedLines: string[] | null = null
51
59
  private cachedWidth = 0
60
+ private cachedStyledWrap = false
52
61
  // Cache content to avoid rescanning children each frame
53
62
  private cachedContent: string | null = null
54
63
  // Cache for styled mode
@@ -56,7 +65,6 @@ export class TextHost extends BaseHost {
56
65
  private cachedSpans: StyledSpan[] | null = null
57
66
  private hasSpans = false
58
67
  private explicitSpans: StyledSpan[] | null = null
59
- private prepared = false
60
68
 
61
69
  constructor(props: TextProps, ctx: HostContext) {
62
70
  super("text", props, ctx)
@@ -94,15 +102,6 @@ export class TextHost extends BaseHost {
94
102
  if (child.content) {
95
103
  spans.push({
96
104
  text: child.content,
97
- // Inherit TextHost's styles
98
- fg: this.fg,
99
- bg: this.bg,
100
- bold: this.bold,
101
- dimmed: this.dimmed,
102
- italic: this.italic,
103
- underline: this.underline,
104
- strikethrough: this.strikethrough,
105
- inverse: this.inverse,
106
105
  })
107
106
  }
108
107
  } else if (child instanceof SpanHost) {
@@ -110,15 +109,15 @@ export class TextHost extends BaseHost {
110
109
  if (content) {
111
110
  spans.push({
112
111
  text: content,
113
- // Span's styles, falling back to TextHost's
114
- fg: child.fg ?? this.fg,
115
- bg: child.bg ?? this.bg,
116
- bold: child.bold || this.bold,
117
- dimmed: child.dimmed || this.dimmed,
118
- italic: child.italic || this.italic,
119
- underline: child.underline || this.underline,
120
- strikethrough: child.strikethrough || this.strikethrough,
121
- inverse: child.inverse || this.inverse,
112
+ // Span's styles (TextHost applies fallbacks at render time)
113
+ fg: child.fg,
114
+ bg: child.bg,
115
+ bold: child.bold,
116
+ dimmed: child.dimmed,
117
+ italic: child.italic,
118
+ underline: child.underline,
119
+ strikethrough: child.strikethrough,
120
+ inverse: child.inverse,
122
121
  })
123
122
  }
124
123
  }
@@ -128,54 +127,51 @@ export class TextHost extends BaseHost {
128
127
  }
129
128
 
130
129
  private prepareContent(): void {
131
- this.invalidateContent()
132
130
  const useExplicitSpans = this.explicitSpans !== null
133
131
  if (useExplicitSpans) {
134
132
  this.hasSpans = false
135
133
  this.cachedSpans = null
136
- this.prepared = true
137
134
  return
138
135
  }
139
136
 
140
137
  this.hasSpans = this.checkForSpans()
141
138
  this.cachedSpans = this.hasSpans ? this.collectSpans() : null
142
- this.prepared = true
143
139
  }
144
140
 
145
- private ensurePrepared(): void {
146
- if (this.prepared) return
147
- this.prepareContent()
148
- }
149
-
150
- /** Invalidate content cache when children change */
151
- private invalidateContent(): void {
141
+ /** Reset content-related caches (text/spans/wrap). */
142
+ private resetContentCaches(): void {
152
143
  this.cachedContent = null
153
144
  this.cachedLines = null
154
145
  this.cachedStyledLines = null
146
+ this.cachedStyledWrap = false
155
147
  this.cachedSpans = null
156
- this.prepared = false
157
148
  }
158
149
 
159
- protected override prepareSelf(): void {
150
+ protected override prepareSelf(_layoutDirty: boolean, _renderDirty: boolean): void {
160
151
  this.prepareContent()
161
152
  }
162
153
 
154
+ override invalidateLayout(): void {
155
+ this.resetContentCaches()
156
+ super.invalidateLayout()
157
+ }
158
+
163
159
  override appendChild(child: HostInstance): void {
160
+ this.resetContentCaches()
164
161
  super.appendChild(child)
165
- this.invalidateContent()
166
162
  }
167
163
 
168
164
  override removeChild(child: HostInstance): void {
165
+ this.resetContentCaches()
169
166
  super.removeChild(child)
170
- this.invalidateContent()
171
167
  }
172
168
 
173
169
  override insertBefore(child: HostInstance, before: HostInstance): void {
170
+ this.resetContentCaches()
174
171
  super.insertBefore(child, before)
175
- this.invalidateContent()
176
172
  }
177
173
 
178
- measure(maxW: number, maxH: number): Size {
174
+ protected measureSelf(maxW: number, maxH: number): Size {
179
175
  const constrained = this.constrainProposal(maxW, maxH)
180
176
  this.ensurePrepared()
181
177
  const useExplicitSpans = this.explicitSpans !== null
@@ -184,18 +180,21 @@ export class TextHost extends BaseHost {
184
180
  if (useExplicitSpans || this.hasSpans) {
185
181
  const spans = useExplicitSpans ? this.explicitSpans! : (this.cachedSpans ?? this.collectSpans())
186
182
  if (this.wrap) {
187
- this.cachedStyledLines = wrapSpans(spans, constrained.w)
183
+ this.cachedStyledLines = wrapSpansByLine(spans, constrained.w)
188
184
  this.cachedWidth = constrained.w
185
+ this.cachedStyledWrap = true
189
186
  const h = Math.min(this.cachedStyledLines.length, constrained.h)
190
- const w = this.cachedStyledLines.reduce(
191
- (max, line) => Math.max(max, line.reduce((sum, span) => sum + displayWidth(span.text), 0)),
192
- 0,
193
- )
187
+ const w = this.cachedStyledLines.reduce((max, line) => Math.max(max, spansDisplayWidth(line)), 0)
194
188
  return this.constrainResult({ w, h })
195
189
  }
196
- // Non-wrap styled mode
197
- const totalWidth = spans.reduce((sum, span) => sum + displayWidth(span.text), 0)
198
- return this.constrainResult({ w: Math.min(totalWidth, constrained.w), h: 1 })
190
+ // Non-wrap styled mode (preserve explicit newlines)
191
+ const lines = splitSpansByNewline(spans)
192
+ this.cachedStyledLines = lines
193
+ this.cachedWidth = constrained.w
194
+ this.cachedStyledWrap = false
195
+ const maxLineWidth = lines.reduce((max, line) => Math.max(max, spansDisplayWidth(line)), 0)
196
+ const h = Math.min(lines.length, constrained.h)
197
+ return this.constrainResult({ w: Math.min(maxLineWidth, constrained.w), h })
199
198
  }
200
199
 
201
200
  // Simple mode: single style for all content
@@ -205,7 +204,7 @@ export class TextHost extends BaseHost {
205
204
  if (this.wrap) {
206
205
  // Wrap mode: may span multiple lines. Cache result for render()
207
206
  this.cachedLines = rawLines.flatMap((line, idx) =>
208
- idx < rawLines.length - 1 ? [...this.wrapText(line, constrained.w), ""] : this.wrapText(line, constrained.w),
207
+ idx < rawLines.length - 1 ? [...wrapText(line, constrained.w), ""] : wrapText(line, constrained.w),
209
208
  )
210
209
  this.cachedWidth = constrained.w
211
210
  const w = this.cachedLines.reduce((max, line) => Math.max(max, displayWidth(line)), 0)
@@ -220,61 +219,7 @@ export class TextHost extends BaseHost {
220
219
  return this.constrainResult({ w, h })
221
220
  }
222
221
 
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
- override layout(rect: Rect): void {
222
+ protected override layoutSelf(rect: Rect): void {
278
223
  const layoutRect = this.layoutWithConstraints(rect)
279
224
  // Layout children (RawTextHost nodes) at same position
280
225
  for (const child of this.children) {
@@ -283,10 +228,7 @@ export class TextHost extends BaseHost {
283
228
  }
284
229
 
285
230
  render(buffer: CellBuffer, palette: Palette): void {
286
- if (!this.rect) {
287
- this.prepared = false
288
- return
289
- }
231
+ if (!this.rect) return
290
232
  this.ensurePrepared()
291
233
 
292
234
  // If text has no bg, inherit from parent box for proper highlight rendering
@@ -297,11 +239,11 @@ export class TextHost extends BaseHost {
297
239
  // Styled mode: render with per-span styles
298
240
  if (useExplicitSpans || this.hasSpans) {
299
241
  const lines =
300
- this.wrap && this.cachedStyledLines && this.cachedWidth === this.rect.w
242
+ this.cachedStyledLines && this.cachedWidth === this.rect.w && this.cachedStyledWrap === this.wrap
301
243
  ? this.cachedStyledLines
302
244
  : (() => {
303
245
  const spans = useExplicitSpans ? this.explicitSpans! : (this.cachedSpans ?? this.collectSpans())
304
- return this.wrap ? wrapSpans(spans, this.rect.w) : [spans]
246
+ return this.wrap ? wrapSpansByLine(spans, this.rect.w) : splitSpansByNewline(spans)
305
247
  })()
306
248
 
307
249
  for (let y = 0; y < Math.min(lines.length, this.rect.h); y++) {
@@ -310,12 +252,12 @@ export class TextHost extends BaseHost {
310
252
  const spanStyleId = styleIdFromProps(palette, {
311
253
  fg: span.fg ?? this.fg,
312
254
  bg: span.bg ?? inheritedBg,
313
- bold: span.bold,
314
- dimmed: span.dimmed,
315
- italic: span.italic,
316
- underline: span.underline,
317
- strikethrough: span.strikethrough,
318
- inverse: span.inverse,
255
+ bold: span.bold ?? this.bold,
256
+ dimmed: span.dimmed ?? this.dimmed,
257
+ italic: span.italic ?? this.italic,
258
+ underline: span.underline ?? this.underline,
259
+ strikethrough: span.strikethrough ?? this.strikethrough,
260
+ inverse: span.inverse ?? this.inverse,
319
261
  })
320
262
  const availableWidth = this.rect.w - (x - this.rect.x)
321
263
  if (availableWidth <= 0) break
@@ -324,7 +266,6 @@ export class TextHost extends BaseHost {
324
266
  x += displayWidth(span.text)
325
267
  }
326
268
  }
327
- this.prepared = false
328
269
  return
329
270
  }
330
271
 
@@ -349,7 +290,7 @@ export class TextHost extends BaseHost {
349
290
  this.cachedLines && this.cachedWidth === rectW
350
291
  ? this.cachedLines
351
292
  : rawLines.flatMap((line, idx) =>
352
- idx < rawLines.length - 1 ? [...this.wrapText(line, rectW), ""] : this.wrapText(line, rectW),
293
+ idx < rawLines.length - 1 ? [...wrapText(line, rectW), ""] : wrapText(line, rectW),
353
294
  )
354
295
  const visibleLines = Math.min(lines.length, this.rect.h)
355
296
  // Explicitly paint background under text lines to clear stale styles (e.g., selection highlights).
@@ -361,7 +302,6 @@ export class TextHost extends BaseHost {
361
302
  const lineWidth = Math.min(displayWidth(lines[i]), this.rect.w)
362
303
  buffer.drawText(this.rect.x, this.rect.y + i, lines[i], styleId, lineWidth)
363
304
  }
364
- this.prepared = false
365
305
  return
366
306
  }
367
307
 
@@ -375,11 +315,21 @@ export class TextHost extends BaseHost {
375
315
  const lineWidth = Math.min(displayWidth(rawLines[i]), this.rect.w)
376
316
  buffer.drawText(this.rect.x, this.rect.y + i, rawLines[i], styleId, lineWidth)
377
317
  }
378
- this.prepared = false
379
318
  }
380
319
 
381
320
  override updateProps(props: Record<string, unknown>): void {
382
321
  super.updateProps(props)
322
+ const prevFg = this.fg
323
+ const prevBg = this.bg
324
+ const prevBold = this.bold
325
+ const prevDimmed = this.dimmed
326
+ const prevItalic = this.italic
327
+ const prevUnderline = this.underline
328
+ const prevStrikethrough = this.strikethrough
329
+ const prevInverse = this.inverse
330
+ const prevWrap = this.wrap
331
+ const prevExplicitSpans = this.explicitSpans
332
+
383
333
  // Color props support MotionValue/ColorMotionValue - auto-subscribe and animate
384
334
  this.fg = this.resolveSpringProp("fg", props.fg, (v) => {
385
335
  this.fg = v as Color
@@ -395,11 +345,31 @@ export class TextHost extends BaseHost {
395
345
  this.inverse = Boolean(props.inverse)
396
346
  this.wrap = Boolean(props.wrap)
397
347
  this.explicitSpans = "spans" in props ? ((props.spans as StyledSpan[] | undefined) ?? []) : null
348
+
349
+ const layoutChanged = prevWrap !== this.wrap || prevExplicitSpans !== this.explicitSpans
350
+ if (layoutChanged) {
351
+ this.invalidateLayout()
352
+ return
353
+ }
354
+
355
+ const renderChanged =
356
+ prevFg !== this.fg ||
357
+ prevBg !== this.bg ||
358
+ prevBold !== this.bold ||
359
+ prevDimmed !== this.dimmed ||
360
+ prevItalic !== this.italic ||
361
+ prevUnderline !== this.underline ||
362
+ prevStrikethrough !== this.strikethrough ||
363
+ prevInverse !== this.inverse
364
+
365
+ if (renderChanged) {
366
+ this.invalidateRender()
367
+ }
398
368
  }
399
369
  }
400
370
 
401
371
  /** Special host for raw text nodes (React text children) */
402
- export class RawTextHost extends BaseHost {
372
+ export class RawTextHost extends LeafHost {
403
373
  content = ""
404
374
 
405
375
  constructor(text: string, ctx: HostContext) {
@@ -407,7 +377,7 @@ export class RawTextHost extends BaseHost {
407
377
  this.content = text
408
378
  }
409
379
 
410
- measure(maxW: number, _maxH: number): Size {
380
+ protected measureSelf(maxW: number, _maxH: number): Size {
411
381
  const w = Math.min(displayWidth(this.content), maxW)
412
382
  return { w, h: 1 }
413
383
  }
@@ -424,6 +394,7 @@ export class RawTextHost extends BaseHost {
424
394
 
425
395
  updateText(text: string): void {
426
396
  this.content = text
397
+ this.parent?.invalidateLayout?.()
427
398
  }
428
399
 
429
400
  override updateProps(_props: Record<string, unknown>): void {
@@ -469,12 +440,12 @@ export interface SpanProps extends CommonProps {
469
440
  export class SpanHost extends BaseHost {
470
441
  fg?: Color
471
442
  bg?: Color
472
- bold = false
473
- dimmed = false
474
- italic = false
475
- underline = false
476
- strikethrough = false
477
- inverse = false
443
+ bold?: boolean
444
+ dimmed?: boolean
445
+ italic?: boolean
446
+ underline?: boolean
447
+ strikethrough?: boolean
448
+ inverse?: boolean
478
449
 
479
450
  constructor(props: SpanProps, ctx: HostContext) {
480
451
  super("span", props, ctx)
@@ -489,7 +460,22 @@ export class SpanHost extends BaseHost {
489
460
  .join("")
490
461
  }
491
462
 
492
- measure(_maxW: number, _maxH: number): Size {
463
+ override appendChild(child: HostInstance): void {
464
+ super.appendChild(child)
465
+ this.parent?.invalidateLayout?.()
466
+ }
467
+
468
+ override removeChild(child: HostInstance): void {
469
+ super.removeChild(child)
470
+ this.parent?.invalidateLayout?.()
471
+ }
472
+
473
+ override insertBefore(child: HostInstance, before: HostInstance): void {
474
+ super.insertBefore(child, before)
475
+ this.parent?.invalidateLayout?.()
476
+ }
477
+
478
+ protected measureSelf(_maxW: number, _maxH: number): Size {
493
479
  // Span doesn't measure independently - parent TextHost handles layout
494
480
  return { w: 0, h: 0 }
495
481
  }
@@ -505,12 +491,20 @@ export class SpanHost extends BaseHost {
505
491
  // Individual props override textStyle object
506
492
  this.fg = props.fg !== undefined ? (props.fg as Color) : textStyle?.fg
507
493
  this.bg = props.bg !== undefined ? (props.bg as Color) : textStyle?.bg
508
- this.bold = props.bold !== undefined ? Boolean(props.bold) : Boolean(textStyle?.bold)
509
- this.dimmed = props.dimmed !== undefined ? Boolean(props.dimmed) : Boolean(textStyle?.dimmed)
510
- this.italic = props.italic !== undefined ? Boolean(props.italic) : Boolean(textStyle?.italic)
511
- this.underline = props.underline !== undefined ? Boolean(props.underline) : Boolean(textStyle?.underline)
512
- this.strikethrough =
513
- props.strikethrough !== undefined ? Boolean(props.strikethrough) : Boolean(textStyle?.strikethrough)
514
- this.inverse = props.inverse !== undefined ? Boolean(props.inverse) : Boolean(textStyle?.inverse)
494
+ this.bold = props.bold !== undefined ? Boolean(props.bold) : textStyle?.bold
495
+ this.dimmed = props.dimmed !== undefined ? Boolean(props.dimmed) : textStyle?.dimmed
496
+ this.italic = props.italic !== undefined ? Boolean(props.italic) : textStyle?.italic
497
+ this.underline = props.underline !== undefined ? Boolean(props.underline) : textStyle?.underline
498
+ this.strikethrough = props.strikethrough !== undefined ? Boolean(props.strikethrough) : textStyle?.strikethrough
499
+ this.inverse = props.inverse !== undefined ? Boolean(props.inverse) : textStyle?.inverse
500
+ this.invalidateLayout()
501
+ }
502
+
503
+ override invalidateLayout(): void {
504
+ this.parent?.invalidateLayout?.()
505
+ }
506
+
507
+ override invalidateRender(): void {
508
+ this.parent?.invalidateRender?.()
515
509
  }
516
510
  }
@@ -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.
@@ -18,7 +19,7 @@ export class ZStackHost extends BaseHost {
18
19
  this.updateProps(props as unknown as Record<string, unknown>)
19
20
  }
20
21
 
21
- measure(maxW: number, maxH: number): Size {
22
+ protected measureSelf(maxW: number, maxH: number): Size {
22
23
  // Apply frame constraints to what we propose to children
23
24
  const constrained = this.constrainProposal(maxW, maxH)
24
25
 
@@ -42,14 +43,13 @@ export class ZStackHost extends BaseHost {
42
43
  return this.constrainResult(naturalSize)
43
44
  }
44
45
 
45
- override layout(rect: Rect): void {
46
+ protected override layoutSelf(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 {
@@ -60,10 +60,15 @@ export class ZStackHost extends BaseHost {
60
60
 
61
61
  override updateProps(props: Record<string, unknown>): void {
62
62
  super.updateProps(props)
63
+ const prevH = this.alignmentH
64
+ const prevV = this.alignmentV
63
65
  if (props.alignment !== undefined) {
64
66
  const a = props.alignment as ZStackProps["alignment"]
65
67
  if (a?.h) this.alignmentH = a.h
66
68
  if (a?.v) this.alignmentV = a.v
67
69
  }
70
+ if (prevH !== this.alignmentH || prevV !== this.alignmentV) {
71
+ this.invalidateLayout()
72
+ }
68
73
  }
69
74
  }
package/src/index.ts CHANGED
@@ -65,9 +65,9 @@ export { useKeyboard, useMouse, usePaste, useQuit, useScroll, useShortcut, useTi
65
65
  export { isKey } from "./shortcuts.js"
66
66
  export { useFrameStats } from "./hooks/useFrameStats.js"
67
67
  export type { BorderKind, BoxProps } from "./hosts/box.js"
68
- export type { CanvasProps, DrawContext } from "./hosts/canvas.js"
68
+ export type { CanvasCell, 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
+ }