@effect-tui/react 0.1.0-alpha.1

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 (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/dist/jsx-dev-runtime.d.ts +3 -0
  4. package/dist/jsx-dev-runtime.d.ts.map +1 -0
  5. package/dist/jsx-dev-runtime.js +3 -0
  6. package/dist/jsx-dev-runtime.js.map +1 -0
  7. package/dist/jsx-runtime.d.ts +47 -0
  8. package/dist/jsx-runtime.d.ts.map +1 -0
  9. package/dist/jsx-runtime.js +6 -0
  10. package/dist/jsx-runtime.js.map +1 -0
  11. package/dist/src/codeblock.d.ts +9 -0
  12. package/dist/src/codeblock.d.ts.map +1 -0
  13. package/dist/src/codeblock.js +24 -0
  14. package/dist/src/codeblock.js.map +1 -0
  15. package/dist/src/constants.d.ts +3 -0
  16. package/dist/src/constants.d.ts.map +1 -0
  17. package/dist/src/constants.js +3 -0
  18. package/dist/src/constants.js.map +1 -0
  19. package/dist/src/debug/DiagnosticsPanel.d.ts +7 -0
  20. package/dist/src/debug/DiagnosticsPanel.d.ts.map +1 -0
  21. package/dist/src/debug/DiagnosticsPanel.js +13 -0
  22. package/dist/src/debug/DiagnosticsPanel.js.map +1 -0
  23. package/dist/src/highlight.d.ts +20 -0
  24. package/dist/src/highlight.d.ts.map +1 -0
  25. package/dist/src/highlight.js +51 -0
  26. package/dist/src/highlight.js.map +1 -0
  27. package/dist/src/hooks/index.d.ts +4 -0
  28. package/dist/src/hooks/index.d.ts.map +1 -0
  29. package/dist/src/hooks/index.js +3 -0
  30. package/dist/src/hooks/index.js.map +1 -0
  31. package/dist/src/hooks/use-keyboard.d.ts +18 -0
  32. package/dist/src/hooks/use-keyboard.d.ts.map +1 -0
  33. package/dist/src/hooks/use-keyboard.js +26 -0
  34. package/dist/src/hooks/use-keyboard.js.map +1 -0
  35. package/dist/src/hooks/use-paste.d.ts +5 -0
  36. package/dist/src/hooks/use-paste.d.ts.map +1 -0
  37. package/dist/src/hooks/use-paste.js +14 -0
  38. package/dist/src/hooks/use-paste.js.map +1 -0
  39. package/dist/src/hooks/useFrameStats.d.ts +7 -0
  40. package/dist/src/hooks/useFrameStats.d.ts.map +1 -0
  41. package/dist/src/hooks/useFrameStats.js +28 -0
  42. package/dist/src/hooks/useFrameStats.js.map +1 -0
  43. package/dist/src/hosts/base.d.ts +22 -0
  44. package/dist/src/hosts/base.d.ts.map +1 -0
  45. package/dist/src/hosts/base.js +53 -0
  46. package/dist/src/hosts/base.js.map +1 -0
  47. package/dist/src/hosts/box.d.ts +26 -0
  48. package/dist/src/hosts/box.d.ts.map +1 -0
  49. package/dist/src/hosts/box.js +84 -0
  50. package/dist/src/hosts/box.js.map +1 -0
  51. package/dist/src/hosts/canvas.d.ts +48 -0
  52. package/dist/src/hosts/canvas.d.ts.map +1 -0
  53. package/dist/src/hosts/canvas.js +109 -0
  54. package/dist/src/hosts/canvas.js.map +1 -0
  55. package/dist/src/hosts/codeblock.d.ts +32 -0
  56. package/dist/src/hosts/codeblock.d.ts.map +1 -0
  57. package/dist/src/hosts/codeblock.js +118 -0
  58. package/dist/src/hosts/codeblock.js.map +1 -0
  59. package/dist/src/hosts/hstack.d.ts +18 -0
  60. package/dist/src/hosts/hstack.d.ts.map +1 -0
  61. package/dist/src/hosts/hstack.js +45 -0
  62. package/dist/src/hosts/hstack.js.map +1 -0
  63. package/dist/src/hosts/index.d.ts +16 -0
  64. package/dist/src/hosts/index.d.ts.map +1 -0
  65. package/dist/src/hosts/index.js +40 -0
  66. package/dist/src/hosts/index.js.map +1 -0
  67. package/dist/src/hosts/spacer.d.ts +19 -0
  68. package/dist/src/hosts/spacer.d.ts.map +1 -0
  69. package/dist/src/hosts/spacer.js +28 -0
  70. package/dist/src/hosts/spacer.js.map +1 -0
  71. package/dist/src/hosts/text.d.ts +43 -0
  72. package/dist/src/hosts/text.d.ts.map +1 -0
  73. package/dist/src/hosts/text.js +148 -0
  74. package/dist/src/hosts/text.js.map +1 -0
  75. package/dist/src/hosts/vstack.d.ts +18 -0
  76. package/dist/src/hosts/vstack.d.ts.map +1 -0
  77. package/dist/src/hosts/vstack.js +45 -0
  78. package/dist/src/hosts/vstack.js.map +1 -0
  79. package/dist/src/hosts/zstack.d.ts +20 -0
  80. package/dist/src/hosts/zstack.d.ts.map +1 -0
  81. package/dist/src/hosts/zstack.js +65 -0
  82. package/dist/src/hosts/zstack.js.map +1 -0
  83. package/dist/src/index.d.ts +20 -0
  84. package/dist/src/index.d.ts.map +1 -0
  85. package/dist/src/index.js +20 -0
  86. package/dist/src/index.js.map +1 -0
  87. package/dist/src/inline/index.d.ts +32 -0
  88. package/dist/src/inline/index.d.ts.map +1 -0
  89. package/dist/src/inline/index.js +111 -0
  90. package/dist/src/inline/index.js.map +1 -0
  91. package/dist/src/jsx.d.ts +2 -0
  92. package/dist/src/jsx.d.ts.map +1 -0
  93. package/dist/src/jsx.js +4 -0
  94. package/dist/src/jsx.js.map +1 -0
  95. package/dist/src/motion/color-motion-value.d.ts +32 -0
  96. package/dist/src/motion/color-motion-value.d.ts.map +1 -0
  97. package/dist/src/motion/color-motion-value.js +80 -0
  98. package/dist/src/motion/color-motion-value.js.map +1 -0
  99. package/dist/src/motion/color.d.ts +30 -0
  100. package/dist/src/motion/color.d.ts.map +1 -0
  101. package/dist/src/motion/color.js +172 -0
  102. package/dist/src/motion/color.js.map +1 -0
  103. package/dist/src/motion/color.test.d.ts +2 -0
  104. package/dist/src/motion/color.test.d.ts.map +1 -0
  105. package/dist/src/motion/color.test.js +97 -0
  106. package/dist/src/motion/color.test.js.map +1 -0
  107. package/dist/src/motion/event-emitter.d.ts +18 -0
  108. package/dist/src/motion/event-emitter.d.ts.map +1 -0
  109. package/dist/src/motion/event-emitter.js +30 -0
  110. package/dist/src/motion/event-emitter.js.map +1 -0
  111. package/dist/src/motion/frame.d.ts +9 -0
  112. package/dist/src/motion/frame.d.ts.map +1 -0
  113. package/dist/src/motion/frame.js +51 -0
  114. package/dist/src/motion/frame.js.map +1 -0
  115. package/dist/src/motion/hooks.d.ts +75 -0
  116. package/dist/src/motion/hooks.d.ts.map +1 -0
  117. package/dist/src/motion/hooks.js +190 -0
  118. package/dist/src/motion/hooks.js.map +1 -0
  119. package/dist/src/motion/index.d.ts +4 -0
  120. package/dist/src/motion/index.d.ts.map +1 -0
  121. package/dist/src/motion/index.js +7 -0
  122. package/dist/src/motion/index.js.map +1 -0
  123. package/dist/src/motion/motion-value.d.ts +40 -0
  124. package/dist/src/motion/motion-value.d.ts.map +1 -0
  125. package/dist/src/motion/motion-value.js +109 -0
  126. package/dist/src/motion/motion-value.js.map +1 -0
  127. package/dist/src/motion/motion-value.test.d.ts +2 -0
  128. package/dist/src/motion/motion-value.test.d.ts.map +1 -0
  129. package/dist/src/motion/motion-value.test.js +177 -0
  130. package/dist/src/motion/motion-value.test.js.map +1 -0
  131. package/dist/src/motion/spring-math.d.ts +28 -0
  132. package/dist/src/motion/spring-math.d.ts.map +1 -0
  133. package/dist/src/motion/spring-math.js +81 -0
  134. package/dist/src/motion/spring-math.js.map +1 -0
  135. package/dist/src/motion/types.d.ts +25 -0
  136. package/dist/src/motion/types.d.ts.map +1 -0
  137. package/dist/src/motion/types.js +13 -0
  138. package/dist/src/motion/types.js.map +1 -0
  139. package/dist/src/output.d.ts +47 -0
  140. package/dist/src/output.d.ts.map +1 -0
  141. package/dist/src/output.js +125 -0
  142. package/dist/src/output.js.map +1 -0
  143. package/dist/src/profiler.d.ts +6 -0
  144. package/dist/src/profiler.d.ts.map +1 -0
  145. package/dist/src/profiler.js +73 -0
  146. package/dist/src/profiler.js.map +1 -0
  147. package/dist/src/reconciler/host-config.d.ts +16 -0
  148. package/dist/src/reconciler/host-config.d.ts.map +1 -0
  149. package/dist/src/reconciler/host-config.js +174 -0
  150. package/dist/src/reconciler/host-config.js.map +1 -0
  151. package/dist/src/reconciler/types.d.ts +52 -0
  152. package/dist/src/reconciler/types.d.ts.map +1 -0
  153. package/dist/src/reconciler/types.js +2 -0
  154. package/dist/src/reconciler/types.js.map +1 -0
  155. package/dist/src/renderer.d.ts +101 -0
  156. package/dist/src/renderer.d.ts.map +1 -0
  157. package/dist/src/renderer.js +509 -0
  158. package/dist/src/renderer.js.map +1 -0
  159. package/dist/src/terminal.d.ts +37 -0
  160. package/dist/src/terminal.d.ts.map +1 -0
  161. package/dist/src/terminal.js +65 -0
  162. package/dist/src/terminal.js.map +1 -0
  163. package/dist/src/test/index.d.ts +3 -0
  164. package/dist/src/test/index.d.ts.map +1 -0
  165. package/dist/src/test/index.js +3 -0
  166. package/dist/src/test/index.js.map +1 -0
  167. package/dist/src/test/mock-streams.d.ts +44 -0
  168. package/dist/src/test/mock-streams.d.ts.map +1 -0
  169. package/dist/src/test/mock-streams.js +136 -0
  170. package/dist/src/test/mock-streams.js.map +1 -0
  171. package/dist/src/test/render-tui.d.ts +47 -0
  172. package/dist/src/test/render-tui.d.ts.map +1 -0
  173. package/dist/src/test/render-tui.js +76 -0
  174. package/dist/src/test/render-tui.js.map +1 -0
  175. package/dist/src/trace/SpanTree.d.ts +10 -0
  176. package/dist/src/trace/SpanTree.d.ts.map +1 -0
  177. package/dist/src/trace/SpanTree.js +104 -0
  178. package/dist/src/trace/SpanTree.js.map +1 -0
  179. package/dist/src/trace/index.d.ts +30 -0
  180. package/dist/src/trace/index.d.ts.map +1 -0
  181. package/dist/src/trace/index.js +142 -0
  182. package/dist/src/trace/index.js.map +1 -0
  183. package/dist/src/trace/location.d.ts +9 -0
  184. package/dist/src/trace/location.d.ts.map +1 -0
  185. package/dist/src/trace/location.js +88 -0
  186. package/dist/src/trace/location.js.map +1 -0
  187. package/dist/src/trace/span-processor.d.ts +16 -0
  188. package/dist/src/trace/span-processor.d.ts.map +1 -0
  189. package/dist/src/trace/span-processor.js +54 -0
  190. package/dist/src/trace/span-processor.js.map +1 -0
  191. package/dist/src/trace/span-state.d.ts +79 -0
  192. package/dist/src/trace/span-state.d.ts.map +1 -0
  193. package/dist/src/trace/span-state.js +229 -0
  194. package/dist/src/trace/span-state.js.map +1 -0
  195. package/dist/src/trace/tui-logger.d.ts +8 -0
  196. package/dist/src/trace/tui-logger.d.ts.map +1 -0
  197. package/dist/src/trace/tui-logger.js +70 -0
  198. package/dist/src/trace/tui-logger.js.map +1 -0
  199. package/dist/src/utils/border.d.ts +31 -0
  200. package/dist/src/utils/border.d.ts.map +1 -0
  201. package/dist/src/utils/border.js +81 -0
  202. package/dist/src/utils/border.js.map +1 -0
  203. package/dist/src/utils/flex-layout.d.ts +20 -0
  204. package/dist/src/utils/flex-layout.d.ts.map +1 -0
  205. package/dist/src/utils/flex-layout.js +85 -0
  206. package/dist/src/utils/flex-layout.js.map +1 -0
  207. package/dist/src/utils/index.d.ts +5 -0
  208. package/dist/src/utils/index.d.ts.map +1 -0
  209. package/dist/src/utils/index.js +5 -0
  210. package/dist/src/utils/index.js.map +1 -0
  211. package/dist/src/utils/padding.d.ts +26 -0
  212. package/dist/src/utils/padding.d.ts.map +1 -0
  213. package/dist/src/utils/padding.js +34 -0
  214. package/dist/src/utils/padding.js.map +1 -0
  215. package/dist/src/utils/styles.d.ts +13 -0
  216. package/dist/src/utils/styles.d.ts.map +1 -0
  217. package/dist/src/utils/styles.js +5 -0
  218. package/dist/src/utils/styles.js.map +1 -0
  219. package/dist/src/visualize/index.d.ts +50 -0
  220. package/dist/src/visualize/index.d.ts.map +1 -0
  221. package/dist/src/visualize/index.js +194 -0
  222. package/dist/src/visualize/index.js.map +1 -0
  223. package/dist/tsconfig.tsbuildinfo +1 -0
  224. package/package.json +94 -0
  225. package/src/codeblock.tsx +47 -0
  226. package/src/constants.ts +2 -0
  227. package/src/debug/DiagnosticsPanel.tsx +38 -0
  228. package/src/highlight.ts +76 -0
  229. package/src/hooks/index.ts +3 -0
  230. package/src/hooks/use-keyboard.ts +37 -0
  231. package/src/hooks/use-paste.ts +14 -0
  232. package/src/hooks/useFrameStats.ts +32 -0
  233. package/src/hosts/base.ts +65 -0
  234. package/src/hosts/box.ts +105 -0
  235. package/src/hosts/canvas.ts +155 -0
  236. package/src/hosts/codeblock.ts +145 -0
  237. package/src/hosts/hstack.ts +64 -0
  238. package/src/hosts/index.ts +45 -0
  239. package/src/hosts/spacer.ts +40 -0
  240. package/src/hosts/text.ts +175 -0
  241. package/src/hosts/vstack.ts +64 -0
  242. package/src/hosts/zstack.ts +77 -0
  243. package/src/index.ts +62 -0
  244. package/src/inline/index.tsx +181 -0
  245. package/src/jsx.ts +3 -0
  246. package/src/motion/color-motion-value.ts +90 -0
  247. package/src/motion/color.test.ts +115 -0
  248. package/src/motion/color.ts +191 -0
  249. package/src/motion/event-emitter.ts +35 -0
  250. package/src/motion/frame.ts +59 -0
  251. package/src/motion/hooks.ts +237 -0
  252. package/src/motion/index.ts +17 -0
  253. package/src/motion/motion-value.test.ts +222 -0
  254. package/src/motion/motion-value.ts +140 -0
  255. package/src/motion/spring-math.ts +114 -0
  256. package/src/motion/types.ts +34 -0
  257. package/src/output.ts +156 -0
  258. package/src/profiler.ts +88 -0
  259. package/src/reconciler/host-config.ts +277 -0
  260. package/src/reconciler/types.ts +66 -0
  261. package/src/renderer.ts +661 -0
  262. package/src/terminal.ts +67 -0
  263. package/src/test/index.ts +8 -0
  264. package/src/test/mock-streams.ts +149 -0
  265. package/src/test/render-tui.ts +118 -0
  266. package/src/trace/SpanTree.tsx +195 -0
  267. package/src/trace/index.tsx +205 -0
  268. package/src/trace/location.ts +90 -0
  269. package/src/trace/span-processor.ts +65 -0
  270. package/src/trace/span-state.ts +286 -0
  271. package/src/trace/tui-logger.ts +72 -0
  272. package/src/utils/border.ts +108 -0
  273. package/src/utils/flex-layout.ts +125 -0
  274. package/src/utils/index.ts +4 -0
  275. package/src/utils/padding.ts +45 -0
  276. package/src/utils/styles.ts +14 -0
  277. package/src/visualize/index.tsx +305 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * React hooks for spring animations.
3
+ */
4
+
5
+ import { useCallback, useEffect, useRef } from "react"
6
+ import { useRenderer } from "../renderer.js"
7
+ import { MotionValue } from "./motion-value.js"
8
+ import { ColorMotionValue } from "./color-motion-value.js"
9
+ import type { SpringOptions } from "./types.js"
10
+ import type { EventName } from "./event-emitter.js"
11
+ import type { ColorInput } from "./color.js"
12
+
13
+ // Global requestRender callback - set by useSpringRenderer hook
14
+ let globalRequestRender: (() => void) | null = null
15
+
16
+ /**
17
+ * Create a MotionValue. Similar to Motion's useMotionValue.
18
+ */
19
+ export function useMotionValue<T>(initial: T): MotionValue<T> {
20
+ const ref = useRef<MotionValue<T> | null>(null)
21
+ if (!ref.current) {
22
+ ref.current = new MotionValue(initial)
23
+ }
24
+
25
+ useEffect(() => {
26
+ return () => ref.current?.destroy()
27
+ }, [])
28
+
29
+ return ref.current
30
+ }
31
+
32
+ /**
33
+ * Call this once in your app to connect springs to the renderer.
34
+ * If no renderer is passed, it will use the nearest RendererContext (useRenderer()).
35
+ */
36
+ export function useSpringRenderer(renderer?: { requestRender: () => void }) {
37
+ // Prefer explicit renderer; otherwise pull from context
38
+ const inferred = renderer ?? useRenderer()
39
+ useEffect(() => {
40
+ if (!inferred) return
41
+ globalRequestRender = inferred.requestRender.bind(inferred)
42
+ return () => {
43
+ if (globalRequestRender === inferred.requestRender) {
44
+ globalRequestRender = null
45
+ }
46
+ }
47
+ }, [inferred])
48
+ }
49
+
50
+ /**
51
+ * Create a spring-animated value. When you call set(), it springs to the new value.
52
+ * Similar to Motion's useSpring.
53
+ *
54
+ * OPTIMIZED: Does NOT use React state during animation. Instead:
55
+ * - Returns MotionValue which you read via mv.get() in draw functions
56
+ * - Calls renderer.requestRender() directly on each frame
57
+ * - Bypasses React reconciliation entirely during animation
58
+ *
59
+ * @example
60
+ * const [xMv, setX] = useSpring(0, { visualDuration: 0.5 })
61
+ * // In canvas draw: const x = xMv.get()
62
+ * // To animate: setX(100)
63
+ */
64
+ export function useSpring(initial: number, options?: SpringOptions): [MotionValue<number>, (value: number) => void] {
65
+ const mv = useMotionValue(initial)
66
+ const optionsRef = useRef(options)
67
+ optionsRef.current = options
68
+
69
+ // Subscribe to changes and request render directly (no setState!)
70
+ useEffect(() => {
71
+ return mv.on("change", () => {
72
+ globalRequestRender?.()
73
+ })
74
+ }, [mv])
75
+
76
+ const set = useCallback(
77
+ (target: number) => {
78
+ mv.set(target, optionsRef.current)
79
+ },
80
+ [mv],
81
+ )
82
+
83
+ return [mv, set]
84
+ }
85
+
86
+ /**
87
+ * Create multiple spring-animated values. Similar to framer-motion's useSprings.
88
+ *
89
+ * @param count - Number of springs to create
90
+ * @param options - Spring configuration (shared by all springs)
91
+ * @returns [mvs, setAll] - Array of MotionValues and a setter function
92
+ *
93
+ * @example
94
+ * const [dotMvs, setDots] = useSprings(9, { visualDuration: 0.3, bounce: 0 })
95
+ *
96
+ * // Update all with a mapper function
97
+ * setDots((i) => i === focusedIndex ? 1 : 0)
98
+ *
99
+ * // Or update with an array
100
+ * setDots([0, 0, 1, 0, 0, 0, 0, 0, 0])
101
+ *
102
+ * // In draw callback
103
+ * const brightness = dotMvs[idx].get()
104
+ */
105
+ export function useSprings(
106
+ count: number,
107
+ options?: SpringOptions,
108
+ ): [MotionValue<number>[], (values: number[] | ((index: number) => number)) => void] {
109
+ const mvsRef = useRef<MotionValue<number>[] | null>(null)
110
+ const optionsRef = useRef(options)
111
+ optionsRef.current = options
112
+
113
+ // Create MotionValues on first render
114
+ if (!mvsRef.current) {
115
+ mvsRef.current = Array.from({ length: count }, () => new MotionValue(0))
116
+ }
117
+
118
+ // Handle count changes (recreate if count changes)
119
+ if (mvsRef.current.length !== count) {
120
+ // Destroy old ones
121
+ for (const mv of mvsRef.current) {
122
+ mv.destroy()
123
+ }
124
+ mvsRef.current = Array.from({ length: count }, () => new MotionValue(0))
125
+ }
126
+
127
+ const mvs = mvsRef.current
128
+
129
+ // Subscribe all to requestRender
130
+ useEffect(() => {
131
+ const unsubs = mvs.map((mv) =>
132
+ mv.on("change", () => {
133
+ globalRequestRender?.()
134
+ }),
135
+ )
136
+ return () => {
137
+ for (const unsub of unsubs) unsub()
138
+ }
139
+ }, [mvs])
140
+
141
+ // Cleanup on unmount
142
+ useEffect(() => {
143
+ return () => {
144
+ if (mvsRef.current) {
145
+ for (const mv of mvsRef.current) {
146
+ mv.destroy()
147
+ }
148
+ }
149
+ }
150
+ }, [])
151
+
152
+ const setAll = useCallback(
153
+ (values: number[] | ((index: number) => number)) => {
154
+ const opts = optionsRef.current
155
+ if (typeof values === "function") {
156
+ for (let i = 0; i < mvs.length; i++) {
157
+ mvs[i].set(values(i), opts)
158
+ }
159
+ } else {
160
+ for (let i = 0; i < Math.min(mvs.length, values.length); i++) {
161
+ mvs[i].set(values[i], opts)
162
+ }
163
+ }
164
+ },
165
+ [mvs],
166
+ )
167
+
168
+ return [mvs, setAll]
169
+ }
170
+
171
+ /**
172
+ * Subscribe to MotionValue events. Similar to Motion's useMotionValueEvent.
173
+ */
174
+ export function useMotionValueEvent<T, E extends EventName>(
175
+ mv: MotionValue<T>,
176
+ event: E,
177
+ callback: E extends "change" ? (value: T) => void : () => void,
178
+ ) {
179
+ const callbackRef = useRef(callback)
180
+ callbackRef.current = callback
181
+
182
+ useEffect(() => {
183
+ return mv.on(event, (v: any) => (callbackRef.current as any)(v))
184
+ }, [mv, event])
185
+ }
186
+
187
+ /**
188
+ * Create a ColorMotionValue hook.
189
+ */
190
+ export function useColorMotionValue(initial: ColorInput): ColorMotionValue {
191
+ const ref = useRef<ColorMotionValue | null>(null)
192
+ if (!ref.current) {
193
+ ref.current = new ColorMotionValue(initial)
194
+ }
195
+
196
+ useEffect(() => {
197
+ return () => ref.current?.destroy()
198
+ }, [])
199
+
200
+ return ref.current
201
+ }
202
+
203
+ /**
204
+ * Create a spring-animated color. Accepts hex, rgb(), hsl(), or {r,g,b} object.
205
+ *
206
+ * @example
207
+ * const [colorMv, setColor] = useColorSpring("#ff0000", { visualDuration: 0.5 })
208
+ * setColor("#00ff00") // Spring to green
209
+ * setColor("hsl(240, 100%, 50%)") // Spring to blue
210
+ *
211
+ * // In draw callback
212
+ * const { r, g, b } = colorMv.get()
213
+ */
214
+ export function useColorSpring(
215
+ initial: ColorInput,
216
+ options?: SpringOptions,
217
+ ): [ColorMotionValue, (color: ColorInput) => void] {
218
+ const mv = useColorMotionValue(initial)
219
+ const optionsRef = useRef(options)
220
+ optionsRef.current = options
221
+
222
+ // Subscribe to changes and request render
223
+ useEffect(() => {
224
+ return mv._subscribeChannels(() => {
225
+ globalRequestRender?.()
226
+ })
227
+ }, [mv])
228
+
229
+ const set = useCallback(
230
+ (color: ColorInput) => {
231
+ mv.set(color, optionsRef.current)
232
+ },
233
+ [mv],
234
+ )
235
+
236
+ return [mv, set]
237
+ }
@@ -0,0 +1,17 @@
1
+ // Motion-inspired animation system for TUI
2
+ // Analytical spring physics (not Euler integration) + setTimeout-based frame loop
3
+
4
+ export type { SpringOptions, MotionValue, RGBA, ColorInput } from "./motion-value.js"
5
+ export {
6
+ motionValue,
7
+ useMotionValue,
8
+ useSpring,
9
+ useSprings,
10
+ useSpringRenderer,
11
+ useMotionValueEvent,
12
+ // Color springs
13
+ ColorMotionValue,
14
+ useColorMotionValue,
15
+ useColorSpring,
16
+ } from "./motion-value.js"
17
+ export { useAnimationFrame } from "./frame.js"
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { motionValue } from "./motion-value.js"
3
+
4
+ describe("MotionValue", () => {
5
+ describe("basic operations", () => {
6
+ it("initializes with correct value", () => {
7
+ const mv = motionValue(100)
8
+ expect(mv.get()).toBe(100)
9
+ })
10
+
11
+ it("jump() sets value immediately", () => {
12
+ const mv = motionValue(0)
13
+ mv.jump(50)
14
+ expect(mv.get()).toBe(50)
15
+ expect(mv.getVelocity()).toBe(0)
16
+ })
17
+
18
+ it("jump() stops any active animation", () => {
19
+ const mv = motionValue(0)
20
+ mv.set(100) // Start animation
21
+ expect(mv.isAnimating()).toBe(true)
22
+ mv.jump(50)
23
+ expect(mv.isAnimating()).toBe(false)
24
+ expect(mv.get()).toBe(50)
25
+ })
26
+ })
27
+
28
+ describe("spring animation", () => {
29
+ it("set() starts animation toward target", () => {
30
+ const mv = motionValue(0)
31
+ mv.set(100)
32
+ expect(mv.isAnimating()).toBe(true)
33
+ })
34
+
35
+ it("notifies on change", () => {
36
+ const mv = motionValue(0)
37
+ const values: number[] = []
38
+ mv.on("change", (v) => values.push(v))
39
+ mv.jump(50)
40
+ expect(values).toContain(50)
41
+ })
42
+
43
+ it("notifies animationStart and animationComplete", async () => {
44
+ const mv = motionValue(0)
45
+ let started = false
46
+ let completed = false
47
+ mv.on("animationStart", () => {
48
+ started = true
49
+ })
50
+ mv.on("animationComplete", () => {
51
+ completed = true
52
+ })
53
+
54
+ mv.set(100, { stiffness: 1000, damping: 50 }) // Fast spring
55
+ expect(started).toBe(true)
56
+
57
+ // Wait for animation to complete
58
+ await new Promise((r) => setTimeout(r, 500))
59
+ expect(completed).toBe(true)
60
+ })
61
+
62
+ it("stop() halts animation", () => {
63
+ const mv = motionValue(0)
64
+ mv.set(100)
65
+ expect(mv.isAnimating()).toBe(true)
66
+ mv.stop()
67
+ expect(mv.isAnimating()).toBe(false)
68
+ })
69
+ })
70
+
71
+ describe("spring physics correctness", () => {
72
+ it("reaches target value when settled", async () => {
73
+ const mv = motionValue(0)
74
+ mv.set(100, { stiffness: 500, damping: 30 })
75
+
76
+ await new Promise((r) => setTimeout(r, 1000))
77
+ expect(mv.get()).toBeCloseTo(100, 1)
78
+ })
79
+
80
+ it("velocity is zero when settled", async () => {
81
+ const mv = motionValue(0)
82
+ mv.set(100, { stiffness: 500, damping: 30 })
83
+
84
+ await new Promise((r) => setTimeout(r, 1000))
85
+ expect(Math.abs(mv.getVelocity())).toBeLessThan(0.1)
86
+ })
87
+ })
88
+
89
+ describe("retargeting", () => {
90
+ it("preserves velocity when retargeting mid-animation", async () => {
91
+ const mv = motionValue(0)
92
+ mv.set(100, { stiffness: 100, damping: 10 })
93
+
94
+ // Wait a bit for velocity to build up
95
+ await new Promise((r) => setTimeout(r, 50))
96
+
97
+ const velocityBefore = mv.getVelocity()
98
+ const positionBefore = mv.get()
99
+
100
+ // Retarget to opposite direction
101
+ mv.set(0, { stiffness: 100, damping: 10 })
102
+
103
+ // Velocity should be preserved (same value, continuing momentum)
104
+ expect(mv.getVelocity()).toBeCloseTo(velocityBefore, 1)
105
+ expect(mv.get()).toBeCloseTo(positionBefore, 1)
106
+ })
107
+
108
+ it("curves smoothly when reversing direction", async () => {
109
+ const mv = motionValue(0)
110
+ mv.set(100, { stiffness: 100, damping: 15 })
111
+
112
+ // Wait for positive velocity
113
+ await new Promise((r) => setTimeout(r, 30))
114
+ const velBefore = mv.getVelocity()
115
+ expect(velBefore).toBeGreaterThan(0)
116
+
117
+ // Retarget back to 0
118
+ mv.set(0, { stiffness: 100, damping: 15 })
119
+
120
+ // Should still have positive velocity immediately after retarget
121
+ expect(mv.getVelocity()).toBeGreaterThan(0)
122
+
123
+ // Wait a bit - velocity should decrease and eventually reverse
124
+ await new Promise((r) => setTimeout(r, 100))
125
+ // Position should have moved past where it was (momentum carried it)
126
+ // or started moving back toward 0
127
+ })
128
+ })
129
+
130
+ describe("visualDuration/bounce API", () => {
131
+ it("visualDuration controls animation speed", async () => {
132
+ const fast = motionValue(0)
133
+ const slow = motionValue(0)
134
+
135
+ fast.set(100, { visualDuration: 0.1, bounce: 0 })
136
+ slow.set(100, { visualDuration: 0.5, bounce: 0 })
137
+
138
+ await new Promise((r) => setTimeout(r, 150))
139
+
140
+ // Fast should be closer to target
141
+ expect(Math.abs(100 - fast.get())).toBeLessThan(Math.abs(100 - slow.get()))
142
+ })
143
+
144
+ it("bounce: 0 means no overshoot (critically damped)", async () => {
145
+ const mv = motionValue(0)
146
+ mv.set(100, { visualDuration: 0.3, bounce: 0 })
147
+
148
+ const values: number[] = []
149
+ mv.on("change", (v) => values.push(v))
150
+
151
+ await new Promise((r) => setTimeout(r, 500))
152
+
153
+ // Should never exceed target
154
+ const max = Math.max(...values)
155
+ expect(max).toBeLessThanOrEqual(100.5) // Small tolerance for numerical precision
156
+ })
157
+
158
+ it("bounce > 0 allows overshoot", async () => {
159
+ const mv = motionValue(0)
160
+ mv.set(100, { visualDuration: 0.3, bounce: 0.3 })
161
+
162
+ const values: number[] = []
163
+ mv.on("change", (v) => values.push(v))
164
+
165
+ await new Promise((r) => setTimeout(r, 500))
166
+
167
+ // Should overshoot target
168
+ const max = Math.max(...values)
169
+ expect(max).toBeGreaterThan(100)
170
+ })
171
+ })
172
+
173
+ describe("cleanup", () => {
174
+ it("destroy() stops animation and clears subscribers", () => {
175
+ const mv = motionValue(0)
176
+ mv.on("change", () => {
177
+ // subscriber added
178
+ })
179
+
180
+ mv.set(100)
181
+ mv.destroy()
182
+
183
+ expect(mv.isAnimating()).toBe(false)
184
+
185
+ // After destroy, just verify it doesn't throw
186
+ mv.jump(50)
187
+ })
188
+
189
+ it("unsubscribe function works", () => {
190
+ const mv = motionValue(0)
191
+ const values: number[] = []
192
+ const unsub = mv.on("change", (v) => values.push(v))
193
+
194
+ mv.jump(10)
195
+ expect(values).toContain(10)
196
+
197
+ unsub()
198
+ mv.jump(20)
199
+ expect(values).not.toContain(20)
200
+ })
201
+ })
202
+ })
203
+
204
+ describe("createSpringResolver", () => {
205
+ // Test the analytical spring formulas directly
206
+ // These are internal but critical for correctness
207
+
208
+ it("initial position equals from", () => {
209
+ const mv = motionValue(50)
210
+ mv.set(100, { stiffness: 100, damping: 10 })
211
+ // At t=0, should still be at initial position
212
+ expect(mv.get()).toBe(50)
213
+ })
214
+
215
+ it("initial velocity is preserved", () => {
216
+ const mv = motionValue(0)
217
+ mv.set(100, { stiffness: 100, damping: 10 })
218
+
219
+ // Initial velocity should be 0 (starting from rest)
220
+ expect(mv.getVelocity()).toBe(0)
221
+ })
222
+ })
@@ -0,0 +1,140 @@
1
+ /**
2
+ * MotionValue - tracks animated values with spring physics.
3
+ * Inspired by framer-motion's MotionValue.
4
+ */
5
+
6
+ import { subscribeFrame } from "./frame.js"
7
+ import { EventEmitter } from "./event-emitter.js"
8
+ import { createSpringResolver, springFromVisualDuration } from "./spring-math.js"
9
+ import { type SpringOptions, DEFAULT_SPRING_OPTIONS } from "./types.js"
10
+
11
+ /** Create a MotionValue. Factory function like Motion's motionValue(). */
12
+ export function motionValue<T>(initial: T): MotionValue<T> {
13
+ return new MotionValue(initial)
14
+ }
15
+
16
+ /**
17
+ * MotionValue tracks a value and its velocity, supporting spring animations.
18
+ */
19
+ export class MotionValue<T = number> extends EventEmitter<T> {
20
+ private current: T
21
+ private target: T
22
+ private velocity = 0
23
+ private animation: { stop: () => void } | null = null
24
+
25
+ constructor(initial: T) {
26
+ super()
27
+ this.current = initial
28
+ this.target = initial
29
+ }
30
+
31
+ /** Get the current value */
32
+ get(): T {
33
+ return this.current
34
+ }
35
+
36
+ /** Set value immediately (no animation) */
37
+ jump(value: T) {
38
+ this.stop()
39
+ this.current = value
40
+ this.target = value
41
+ this.velocity = 0
42
+ this.notify("change", value)
43
+ }
44
+
45
+ /** Set target and animate with spring */
46
+ set(value: T, options?: SpringOptions) {
47
+ if (typeof value !== "number" || typeof this.current !== "number") {
48
+ // Non-numeric: jump immediately
49
+ this.jump(value)
50
+ return
51
+ }
52
+ this.target = value
53
+ this.animateSpring(options)
54
+ }
55
+
56
+ /** Get current velocity */
57
+ getVelocity(): number {
58
+ return this.velocity
59
+ }
60
+
61
+ /** Check if currently animating */
62
+ isAnimating(): boolean {
63
+ return this.animation !== null
64
+ }
65
+
66
+ /** Stop any active animation */
67
+ stop() {
68
+ if (this.animation) {
69
+ this.animation.stop()
70
+ this.animation = null
71
+ }
72
+ }
73
+
74
+ /** Destroy and clean up */
75
+ destroy() {
76
+ this.stop()
77
+ this.clearSubscribers()
78
+ }
79
+
80
+ private animateSpring(options?: SpringOptions) {
81
+ this.stop()
82
+
83
+ const opts = { ...DEFAULT_SPRING_OPTIONS, ...options }
84
+ const from = this.current as number
85
+ const to = this.target as number
86
+
87
+ if (from === to) return
88
+
89
+ // Derive stiffness/damping from visualDuration/bounce if provided
90
+ let { stiffness, damping, mass } = opts
91
+ if (opts.visualDuration && opts.visualDuration > 0) {
92
+ const derived = springFromVisualDuration(opts.visualDuration, opts.bounce)
93
+ stiffness = derived.stiffness
94
+ damping = derived.damping
95
+ }
96
+
97
+ // Create spring resolver (analytical solution)
98
+ const resolver = createSpringResolver(from, to, this.velocity, stiffness, damping, mass)
99
+
100
+ this.notify("animationStart", undefined)
101
+ const startTime = Date.now()
102
+
103
+ const unsubscribe = subscribeFrame(() => {
104
+ const elapsed = (Date.now() - startTime) / 1000 // seconds
105
+
106
+ const state = resolver(elapsed)
107
+ this.current = state.value as T
108
+ this.velocity = state.velocity
109
+
110
+ this.notify("change", this.current)
111
+
112
+ if (state.done) {
113
+ this.current = to as T
114
+ this.velocity = 0
115
+ this.animation = null
116
+ this.notify("change", this.current)
117
+ this.notify("animationComplete", undefined)
118
+ unsubscribe()
119
+ }
120
+ })
121
+
122
+ this.animation = { stop: unsubscribe }
123
+ }
124
+ }
125
+
126
+ // Re-export everything for backwards compatibility
127
+ export { type SpringOptions, DEFAULT_SPRING_OPTIONS } from "./types.js"
128
+ export type { EventName, Subscriber } from "./event-emitter.js"
129
+ export { createSpringResolver, springFromVisualDuration, isSpringSettled } from "./spring-math.js"
130
+ export { ColorMotionValue } from "./color-motion-value.js"
131
+ export {
132
+ useMotionValue,
133
+ useSpring,
134
+ useSprings,
135
+ useSpringRenderer,
136
+ useMotionValueEvent,
137
+ useColorMotionValue,
138
+ useColorSpring,
139
+ } from "./hooks.js"
140
+ export type { RGBA, ColorInput } from "./color.js"