@effect-tui/core 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 (241) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -0
  3. package/dist/anim.d.ts +4 -0
  4. package/dist/anim.d.ts.map +1 -0
  5. package/dist/anim.js +5 -0
  6. package/dist/anim.js.map +1 -0
  7. package/dist/ansi.d.ts +69 -0
  8. package/dist/ansi.d.ts.map +1 -0
  9. package/dist/ansi.js +72 -0
  10. package/dist/ansi.js.map +1 -0
  11. package/dist/index.d.ts +16 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +16 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/keys.d.ts +18 -0
  16. package/dist/keys.d.ts.map +1 -0
  17. package/dist/keys.js +247 -0
  18. package/dist/keys.js.map +1 -0
  19. package/dist/layout/linearStack.d.ts +17 -0
  20. package/dist/layout/linearStack.d.ts.map +1 -0
  21. package/dist/layout/linearStack.js +86 -0
  22. package/dist/layout/linearStack.js.map +1 -0
  23. package/dist/motion-value.d.ts +58 -0
  24. package/dist/motion-value.d.ts.map +1 -0
  25. package/dist/motion-value.js +250 -0
  26. package/dist/motion-value.js.map +1 -0
  27. package/dist/present/display.d.ts +58 -0
  28. package/dist/present/display.d.ts.map +1 -0
  29. package/dist/present/display.js +168 -0
  30. package/dist/present/display.js.map +1 -0
  31. package/dist/present/writers/fullscreen.d.ts +19 -0
  32. package/dist/present/writers/fullscreen.d.ts.map +1 -0
  33. package/dist/present/writers/fullscreen.js +55 -0
  34. package/dist/present/writers/fullscreen.js.map +1 -0
  35. package/dist/present/writers/inline.d.ts +20 -0
  36. package/dist/present/writers/inline.d.ts.map +1 -0
  37. package/dist/present/writers/inline.js +92 -0
  38. package/dist/present/writers/inline.js.map +1 -0
  39. package/dist/render/buffer.d.ts +31 -0
  40. package/dist/render/buffer.d.ts.map +1 -0
  41. package/dist/render/buffer.js +183 -0
  42. package/dist/render/buffer.js.map +1 -0
  43. package/dist/render/color-utils.d.ts +18 -0
  44. package/dist/render/color-utils.d.ts.map +1 -0
  45. package/dist/render/color-utils.js +58 -0
  46. package/dist/render/color-utils.js.map +1 -0
  47. package/dist/render/diff.d.ts +30 -0
  48. package/dist/render/diff.d.ts.map +1 -0
  49. package/dist/render/diff.js +83 -0
  50. package/dist/render/diff.js.map +1 -0
  51. package/dist/render/measure.d.ts +15 -0
  52. package/dist/render/measure.d.ts.map +1 -0
  53. package/dist/render/measure.js +65 -0
  54. package/dist/render/measure.js.map +1 -0
  55. package/dist/render/palette.d.ts +46 -0
  56. package/dist/render/palette.d.ts.map +1 -0
  57. package/dist/render/palette.js +108 -0
  58. package/dist/render/palette.js.map +1 -0
  59. package/dist/render/surface.d.ts +77 -0
  60. package/dist/render/surface.d.ts.map +1 -0
  61. package/dist/render/surface.js +198 -0
  62. package/dist/render/surface.js.map +1 -0
  63. package/dist/runtime/backend_node.d.ts +36 -0
  64. package/dist/runtime/backend_node.d.ts.map +1 -0
  65. package/dist/runtime/backend_node.js +66 -0
  66. package/dist/runtime/backend_node.js.map +1 -0
  67. package/dist/spring-physics.d.ts +36 -0
  68. package/dist/spring-physics.d.ts.map +1 -0
  69. package/dist/spring-physics.js +113 -0
  70. package/dist/spring-physics.js.map +1 -0
  71. package/dist/spring.d.ts +73 -0
  72. package/dist/spring.d.ts.map +1 -0
  73. package/dist/spring.js +136 -0
  74. package/dist/spring.js.map +1 -0
  75. package/dist/ui/containers/canvas.d.ts +13 -0
  76. package/dist/ui/containers/canvas.d.ts.map +1 -0
  77. package/dist/ui/containers/canvas.js +16 -0
  78. package/dist/ui/containers/canvas.js.map +1 -0
  79. package/dist/ui/containers/geometry-reader.d.ts +17 -0
  80. package/dist/ui/containers/geometry-reader.d.ts.map +1 -0
  81. package/dist/ui/containers/geometry-reader.js +24 -0
  82. package/dist/ui/containers/geometry-reader.js.map +1 -0
  83. package/dist/ui/containers/hstack.d.ts +12 -0
  84. package/dist/ui/containers/hstack.d.ts.map +1 -0
  85. package/dist/ui/containers/hstack.js +28 -0
  86. package/dist/ui/containers/hstack.js.map +1 -0
  87. package/dist/ui/containers/scroll.d.ts +28 -0
  88. package/dist/ui/containers/scroll.d.ts.map +1 -0
  89. package/dist/ui/containers/scroll.js +97 -0
  90. package/dist/ui/containers/scroll.js.map +1 -0
  91. package/dist/ui/containers/shared.d.ts +12 -0
  92. package/dist/ui/containers/shared.d.ts.map +1 -0
  93. package/dist/ui/containers/shared.js +19 -0
  94. package/dist/ui/containers/shared.js.map +1 -0
  95. package/dist/ui/containers/vstack.d.ts +12 -0
  96. package/dist/ui/containers/vstack.d.ts.map +1 -0
  97. package/dist/ui/containers/vstack.js +28 -0
  98. package/dist/ui/containers/vstack.js.map +1 -0
  99. package/dist/ui/containers/zstack.d.ts +14 -0
  100. package/dist/ui/containers/zstack.d.ts.map +1 -0
  101. package/dist/ui/containers/zstack.js +36 -0
  102. package/dist/ui/containers/zstack.js.map +1 -0
  103. package/dist/ui/core/geometry-store.d.ts +22 -0
  104. package/dist/ui/core/geometry-store.d.ts.map +1 -0
  105. package/dist/ui/core/geometry-store.js +29 -0
  106. package/dist/ui/core/geometry-store.js.map +1 -0
  107. package/dist/ui/core/geometry.d.ts +34 -0
  108. package/dist/ui/core/geometry.d.ts.map +1 -0
  109. package/dist/ui/core/geometry.js +14 -0
  110. package/dist/ui/core/geometry.js.map +1 -0
  111. package/dist/ui/core/view.d.ts +25 -0
  112. package/dist/ui/core/view.d.ts.map +1 -0
  113. package/dist/ui/core/view.js +34 -0
  114. package/dist/ui/core/view.js.map +1 -0
  115. package/dist/ui/index.d.ts +44 -0
  116. package/dist/ui/index.d.ts.map +1 -0
  117. package/dist/ui/index.js +39 -0
  118. package/dist/ui/index.js.map +1 -0
  119. package/dist/ui/inlinetext.d.ts +24 -0
  120. package/dist/ui/inlinetext.d.ts.map +1 -0
  121. package/dist/ui/inlinetext.js +131 -0
  122. package/dist/ui/inlinetext.js.map +1 -0
  123. package/dist/ui/install.d.ts +22 -0
  124. package/dist/ui/install.d.ts.map +1 -0
  125. package/dist/ui/install.js +66 -0
  126. package/dist/ui/install.js.map +1 -0
  127. package/dist/ui/markdown.d.ts +40 -0
  128. package/dist/ui/markdown.d.ts.map +1 -0
  129. package/dist/ui/markdown.js +351 -0
  130. package/dist/ui/markdown.js.map +1 -0
  131. package/dist/ui/modifiers/border.d.ts +33 -0
  132. package/dist/ui/modifiers/border.d.ts.map +1 -0
  133. package/dist/ui/modifiers/border.js +82 -0
  134. package/dist/ui/modifiers/border.js.map +1 -0
  135. package/dist/ui/modifiers/fill.d.ts +14 -0
  136. package/dist/ui/modifiers/fill.d.ts.map +1 -0
  137. package/dist/ui/modifiers/fill.js +25 -0
  138. package/dist/ui/modifiers/fill.js.map +1 -0
  139. package/dist/ui/modifiers/frame.d.ts +23 -0
  140. package/dist/ui/modifiers/frame.d.ts.map +1 -0
  141. package/dist/ui/modifiers/frame.js +54 -0
  142. package/dist/ui/modifiers/frame.js.map +1 -0
  143. package/dist/ui/modifiers/offset.d.ts +15 -0
  144. package/dist/ui/modifiers/offset.d.ts.map +1 -0
  145. package/dist/ui/modifiers/offset.js +21 -0
  146. package/dist/ui/modifiers/offset.js.map +1 -0
  147. package/dist/ui/modifiers/opacity.d.ts +15 -0
  148. package/dist/ui/modifiers/opacity.d.ts.map +1 -0
  149. package/dist/ui/modifiers/opacity.js +95 -0
  150. package/dist/ui/modifiers/opacity.js.map +1 -0
  151. package/dist/ui/modifiers/padding.d.ts +20 -0
  152. package/dist/ui/modifiers/padding.d.ts.map +1 -0
  153. package/dist/ui/modifiers/padding.js +36 -0
  154. package/dist/ui/modifiers/padding.js.map +1 -0
  155. package/dist/ui/modifiers/styled.d.ts +14 -0
  156. package/dist/ui/modifiers/styled.d.ts.map +1 -0
  157. package/dist/ui/modifiers/styled.js +26 -0
  158. package/dist/ui/modifiers/styled.js.map +1 -0
  159. package/dist/ui/primitives/rectangle.d.ts +15 -0
  160. package/dist/ui/primitives/rectangle.d.ts.map +1 -0
  161. package/dist/ui/primitives/rectangle.js +23 -0
  162. package/dist/ui/primitives/rectangle.js.map +1 -0
  163. package/dist/ui/primitives/spacer.d.ts +13 -0
  164. package/dist/ui/primitives/spacer.d.ts.map +1 -0
  165. package/dist/ui/primitives/spacer.js +16 -0
  166. package/dist/ui/primitives/spacer.js.map +1 -0
  167. package/dist/ui/primitives/text.d.ts +15 -0
  168. package/dist/ui/primitives/text.d.ts.map +1 -0
  169. package/dist/ui/primitives/text.js +79 -0
  170. package/dist/ui/primitives/text.js.map +1 -0
  171. package/dist/ui/primitives/wrapped-text.d.ts +30 -0
  172. package/dist/ui/primitives/wrapped-text.d.ts.map +1 -0
  173. package/dist/ui/primitives/wrapped-text.js +117 -0
  174. package/dist/ui/primitives/wrapped-text.js.map +1 -0
  175. package/dist/ui/shinytext.d.ts +66 -0
  176. package/dist/ui/shinytext.d.ts.map +1 -0
  177. package/dist/ui/shinytext.js +99 -0
  178. package/dist/ui/shinytext.js.map +1 -0
  179. package/dist/ui/text/layout.d.ts +35 -0
  180. package/dist/ui/text/layout.d.ts.map +1 -0
  181. package/dist/ui/text/layout.js +102 -0
  182. package/dist/ui/text/layout.js.map +1 -0
  183. package/dist/ui/textinput.d.ts +140 -0
  184. package/dist/ui/textinput.d.ts.map +1 -0
  185. package/dist/ui/textinput.js +402 -0
  186. package/dist/ui/textinput.js.map +1 -0
  187. package/dist/ui/view-constructors.d.ts +72 -0
  188. package/dist/ui/view-constructors.d.ts.map +1 -0
  189. package/dist/ui/view-constructors.js +74 -0
  190. package/dist/ui/view-constructors.js.map +1 -0
  191. package/package.json +57 -0
  192. package/src/anim.ts +5 -0
  193. package/src/ansi.ts +83 -0
  194. package/src/index.ts +21 -0
  195. package/src/keys.ts +302 -0
  196. package/src/layout/linearStack.ts +115 -0
  197. package/src/motion-value.ts +335 -0
  198. package/src/present/display.ts +206 -0
  199. package/src/present/writers/fullscreen.ts +58 -0
  200. package/src/present/writers/inline.ts +101 -0
  201. package/src/render/buffer.ts +200 -0
  202. package/src/render/color-utils.ts +60 -0
  203. package/src/render/diff.ts +95 -0
  204. package/src/render/measure.ts +74 -0
  205. package/src/render/palette.ts +113 -0
  206. package/src/render/surface.ts +238 -0
  207. package/src/runtime/backend_node.ts +80 -0
  208. package/src/spring-physics.ts +151 -0
  209. package/src/spring.ts +234 -0
  210. package/src/ui/__snapshots__/wrappedtext.test.ts.snap +57 -0
  211. package/src/ui/containers/canvas.ts +18 -0
  212. package/src/ui/containers/geometry-reader.ts +32 -0
  213. package/src/ui/containers/hstack.ts +33 -0
  214. package/src/ui/containers/scroll.ts +106 -0
  215. package/src/ui/containers/shared.ts +27 -0
  216. package/src/ui/containers/vstack.ts +34 -0
  217. package/src/ui/containers/zstack.ts +37 -0
  218. package/src/ui/core/geometry-store.ts +42 -0
  219. package/src/ui/core/geometry.ts +30 -0
  220. package/src/ui/core/view.ts +49 -0
  221. package/src/ui/index.ts +84 -0
  222. package/src/ui/inlinetext.ts +135 -0
  223. package/src/ui/install.ts +110 -0
  224. package/src/ui/markdown.test.ts +74 -0
  225. package/src/ui/markdown.ts +388 -0
  226. package/src/ui/modifiers/border.ts +100 -0
  227. package/src/ui/modifiers/fill.ts +28 -0
  228. package/src/ui/modifiers/frame.ts +74 -0
  229. package/src/ui/modifiers/offset.ts +23 -0
  230. package/src/ui/modifiers/opacity.ts +93 -0
  231. package/src/ui/modifiers/padding.ts +53 -0
  232. package/src/ui/modifiers/styled.ts +31 -0
  233. package/src/ui/primitives/rectangle.ts +25 -0
  234. package/src/ui/primitives/spacer.ts +18 -0
  235. package/src/ui/primitives/text.ts +85 -0
  236. package/src/ui/primitives/wrapped-text.ts +131 -0
  237. package/src/ui/shinytext.ts +159 -0
  238. package/src/ui/text/layout.ts +119 -0
  239. package/src/ui/textinput.ts +496 -0
  240. package/src/ui/view-constructors.ts +96 -0
  241. package/src/ui/wrappedtext.test.ts +138 -0
@@ -0,0 +1,238 @@
1
+ import { CellBuffer, type Wcwidth, defaultWcwidth } from "./buffer.js"
2
+ import { Palette, ScopedPalette, type ColorValue, type StyleSpec, mergeStyle } from "./palette.js"
3
+
4
+ // Re-export palette types for other modules
5
+ export type { ColorValue, StyleSpec }
6
+ export { Palette, mergeStyle }
7
+
8
+ /** Color inputs accepted by helpers: numeric, rgb object, or string (hex/name/grayN). */
9
+ export type ColorLike = ColorValue | string
10
+
11
+ export const BASE_NAMES = {
12
+ black: 0,
13
+ red: 1,
14
+ green: 2,
15
+ yellow: 3,
16
+ blue: 4,
17
+ magenta: 5,
18
+ cyan: 6,
19
+ white: 7,
20
+ brightBlack: 8,
21
+ brightRed: 9,
22
+ brightGreen: 10,
23
+ brightYellow: 11,
24
+ brightBlue: 12,
25
+ brightMagenta: 13,
26
+ brightCyan: 14,
27
+ brightWhite: 15,
28
+ } as const
29
+
30
+ /**
31
+ * Parse flexible color inputs into a ColorValue.
32
+ * Supports: number (0..255), {r,g,b}, "#rrggbb", base names, and gray0..gray23.
33
+ */
34
+ export function parseColor(c: ColorLike): ColorValue {
35
+ if (typeof c === "number") return c
36
+ if (typeof c === "object") return { r: c.r | 0, g: c.g | 0, b: c.b | 0 }
37
+ const s = String(c).trim()
38
+
39
+ if (s.startsWith("#")) {
40
+ const hex = s.slice(1)
41
+ if (hex.length === 6) {
42
+ const r = parseInt(hex.slice(0, 2), 16)
43
+ const g = parseInt(hex.slice(2, 4), 16)
44
+ const b = parseInt(hex.slice(4, 6), 16)
45
+ return { r, g, b }
46
+ }
47
+ }
48
+
49
+ if (s in BASE_NAMES) return BASE_NAMES[s as keyof typeof BASE_NAMES]
50
+
51
+ const m = /^(?:gray|grey)(\d{1,2})$/.exec(s)
52
+ if (m?.[1]) {
53
+ const n = Math.max(0, Math.min(23, parseInt(m[1], 10)))
54
+ return 232 + n
55
+ }
56
+
57
+ return 7 // default to white if unrecognized
58
+ }
59
+
60
+ type ColorsApi = typeof BASE_NAMES & {
61
+ rgb(r: number, g: number, b: number): ColorValue
62
+ hex(hex: string): ColorValue
63
+ gray(level: number): number
64
+ }
65
+
66
+ export const Colors: ColorsApi = Object.assign(
67
+ {
68
+ rgb(r: number, g: number, b: number): ColorValue {
69
+ return { r, g, b }
70
+ },
71
+ hex(hex: string): ColorValue {
72
+ return parseColor(hex)
73
+ },
74
+ gray(level: number): number {
75
+ const n = Math.max(0, Math.min(23, level | 0))
76
+ return 232 + n
77
+ },
78
+ },
79
+ BASE_NAMES,
80
+ ) as ColorsApi
81
+
82
+ export function derivePalette(p: Palette, base?: StyleSpec): Palette {
83
+ return new ScopedPalette(p, base)
84
+ }
85
+
86
+ export class Surface {
87
+ readonly w: number
88
+ readonly h: number
89
+ private B: CellBuffer
90
+ private palette: Palette
91
+
92
+ constructor(width: number, height: number, opts?: { palette?: Palette; wcwidth?: Wcwidth }) {
93
+ this.w = Math.max(1, width | 0)
94
+ this.h = Math.max(1, height | 0)
95
+
96
+ const wc = opts?.wcwidth ?? defaultWcwidth
97
+ this.B = new CellBuffer(this.w, this.h, wc)
98
+ this.palette = opts?.palette ?? new Palette()
99
+
100
+ this.B.clear(0)
101
+ }
102
+
103
+ /** Clear the back buffer (B) to spaces in the given style. */
104
+ clear(styleId = 0): void {
105
+ this.B.clear(styleId)
106
+ }
107
+
108
+ /** Draw a single code point at (x,y). If width=2, marks the following cell as continuation (w=0). */
109
+ drawCP(x: number, y: number, cp: number, styleId = 0): void {
110
+ this.B.drawCP(x, y, cp, styleId)
111
+ }
112
+
113
+ /** Draw plain text (no wrapping) clipped to width. Uses Intl.Segmenter if available. */
114
+ drawText(x: number, y: number, text: string, styleId = 0, maxWidth?: number): void {
115
+ this.B.drawText(x, y, text, styleId, maxWidth)
116
+ }
117
+
118
+ /** Fill a rectangle with a code point + style. */
119
+ fillRect(x: number, y: number, w: number, h: number, cp = 32, styleId = 0): void {
120
+ this.B.fillRect(x, y, w, h, cp, styleId)
121
+ }
122
+
123
+ // --- Clipping helpers (forwarded to back buffer) ---
124
+
125
+ pushClip(x: number, y: number, w: number, h: number): void {
126
+ this.B.pushClip(x, y, w, h)
127
+ }
128
+
129
+ popClip(): void {
130
+ this.B.popClip()
131
+ }
132
+
133
+ withClip(x: number, y: number, w: number, h: number, fn: () => void): void {
134
+ this.B.withClip(x, y, w, h, fn)
135
+ }
136
+
137
+ // --- Offset helpers (forwarded to back buffer) ---
138
+
139
+ pushOffset(dx: number, dy: number): void {
140
+ this.B.pushOffset(dx, dy)
141
+ }
142
+
143
+ popOffset(): void {
144
+ this.B.popOffset()
145
+ }
146
+
147
+ withOffset(dx: number, dy: number, fn: () => void): void {
148
+ this.B.withOffset(dx, dy, fn)
149
+ }
150
+
151
+ /** Convert surface to plain text (for testing). */
152
+ toString(): string {
153
+ const lines: string[] = []
154
+ for (let y = 0; y < this.h; y++) {
155
+ const row = y * this.w
156
+ let line = ""
157
+ for (let x = 0; x < this.w; x++) {
158
+ const i = row + x
159
+ const cp = this.B.g[i]
160
+ const w = this.B.cw[i]
161
+ if (w !== 0) line += cp === 32 ? " " : String.fromCodePoint(cp)
162
+ }
163
+ // trim right spaces
164
+ line = line.replace(/\s+$/, "")
165
+ lines.push(line)
166
+ }
167
+ return lines.join("\n")
168
+ }
169
+
170
+ /** Compute tight bounding box of content in back buffer: any non-space glyph or non-default style. */
171
+ contentBounds(): { x: number; y: number; w: number; h: number } {
172
+ const W = this.w
173
+ const H = this.h
174
+ const g = this.B.g
175
+ const s = this.B.s
176
+ const cw = this.B.cw
177
+ let minX = W,
178
+ minY = H,
179
+ maxX = -1,
180
+ maxY = -1
181
+ for (let y = 0; y < H; y++) {
182
+ const row = y * W
183
+ for (let x = 0; x < W; x++) {
184
+ const i = row + x
185
+ if (cw[i] === 0) continue
186
+ if (g[i] !== 32 || s[i] !== 0) {
187
+ if (x < minX) minX = x
188
+ if (y < minY) minY = y
189
+ if (x > maxX) maxX = x
190
+ if (y > maxY) maxY = y
191
+ }
192
+ }
193
+ }
194
+ if (maxX < minX || maxY < minY) return { x: 0, y: 0, w: 0, h: 0 }
195
+ return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }
196
+ }
197
+
198
+ /** Build styled text lines for a given bounds region of the back buffer (without trailing newlines). */
199
+ buildStyledLines(bounds: { x: number; y: number; w: number; h: number }, palette: Palette = this.palette): string[] {
200
+ const W = this.w
201
+ const g = this.B.g
202
+ const s = this.B.s
203
+ const cw = this.B.cw
204
+ const lines: string[] = []
205
+ const { x: bx, y: by, w: bw, h: bh } = bounds
206
+ const endY = Math.min(by + bh, this.h)
207
+ const endX = Math.min(bx + bw, W)
208
+
209
+ for (let y = Math.max(0, by); y < endY; y++) {
210
+ const row = y * W
211
+ let line = ""
212
+ let currentStyle = -1
213
+
214
+ for (let x = Math.max(0, bx); x < endX; x++) {
215
+ const i = row + x
216
+ if (cw[i] === 0) continue // skip continuation cells
217
+
218
+ const styleId = s[i]
219
+ if (styleId !== currentStyle) {
220
+ line += palette.sgr(styleId)
221
+ currentStyle = styleId
222
+ }
223
+
224
+ const cp = g[i]
225
+ line += cp === 32 ? " " : String.fromCodePoint(cp)
226
+ }
227
+
228
+ // reset style at end of line
229
+ if (currentStyle !== 0) {
230
+ line += palette.sgr(0)
231
+ }
232
+
233
+ lines.push(line)
234
+ }
235
+
236
+ return lines
237
+ }
238
+ }
@@ -0,0 +1,80 @@
1
+ import { ANSI, Terminal } from "../ansi.js"
2
+
3
+ export interface TerminalBackend {
4
+ size(): { cols: number; rows: number }
5
+ write(s: string): void
6
+ enterFullscreen(): void
7
+ exitFullscreen(): void
8
+ hideCursor(): void
9
+ showCursor(): void
10
+ enableMouse(): void
11
+ disableMouse(): void
12
+ /** Query terminal for current cursor position (1-based). */
13
+ getCursorPosition(): Promise<{ row: number; col: number }>
14
+ }
15
+
16
+ export class TerminalBackendLive implements TerminalBackend {
17
+ size() {
18
+ return { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
19
+ }
20
+ write(s: string) {
21
+ try {
22
+ if (!process.stdout.write(s)) {
23
+ // queue a microtask to flush on 'drain' without blocking
24
+ const onDrain = () => process.stdout.off("drain", onDrain)
25
+ process.stdout.on("drain", onDrain)
26
+ }
27
+ } catch {}
28
+ }
29
+ enterFullscreen() {
30
+ this.write(Terminal.enterFullscreen)
31
+ this.write(Terminal.bracketedPasteOn)
32
+ this.enableMouse()
33
+ }
34
+ exitFullscreen() {
35
+ this.disableMouse()
36
+ this.write(Terminal.bracketedPasteOff)
37
+ this.write(Terminal.exitFullscreen)
38
+ }
39
+ hideCursor() {
40
+ this.write(ANSI.cursor.hide)
41
+ }
42
+ showCursor() {
43
+ this.write(ANSI.cursor.show)
44
+ }
45
+ enableMouse() {
46
+ this.write(Terminal.mouseOn)
47
+ }
48
+ disableMouse() {
49
+ this.write(Terminal.mouseOff)
50
+ }
51
+
52
+ getCursorPosition(): Promise<{ row: number; col: number }> {
53
+ return new Promise((resolve) => {
54
+ const inp = process.stdin
55
+ let buf = ""
56
+ const onData = (b: Buffer) => {
57
+ buf += b.toString("utf8")
58
+ // Look for ESC [ row ; col R
59
+ const m = /\x1b\[(\d+);(\d+)R/.exec(buf)
60
+ if (m) {
61
+ cleanup()
62
+ resolve({ row: parseInt(m[1], 10) || 1, col: parseInt(m[2], 10) || 1 })
63
+ }
64
+ }
65
+ const cleanup = () => {
66
+ clearTimeout(timer)
67
+ inp.off("data", onData)
68
+ }
69
+ const timer = setTimeout(() => {
70
+ cleanup()
71
+ // Fallback: best effort guess (bottom-left)
72
+ resolve({ row: process.stdout.rows || 24, col: 1 })
73
+ }, 120)
74
+
75
+ inp.on("data", onData)
76
+ // Device Status Report: “Report Cursor Position”
77
+ this.write(Terminal.reportCursorPosition)
78
+ })
79
+ }
80
+ }
@@ -0,0 +1,151 @@
1
+ // spring-physics.ts - Core spring physics primitives
2
+
3
+ export type SpringState = {
4
+ // current normalized position (e.g., 0..1)
5
+ x: number
6
+ // current velocity in units per second
7
+ v: number
8
+ // target normalized position
9
+ target: number
10
+ }
11
+
12
+ export type SpringConfig = {
13
+ // natural frequency in Hz (cycles per second). e.g., 3 = fairly snappy
14
+ frequency?: number
15
+ // damping ratio (1 = critical damping, < 1 underdamped, > 1 overdamped)
16
+ dampingRatio?: number
17
+ // when |x-target| and |v| fall below epsilon, consider settled
18
+ epsilon?: number
19
+ // Optional UX-style controls akin to Motion libraries:
20
+ // approximate settle time in seconds (to ~2% band)
21
+ duration?: number
22
+ // bounce amount (0..1), maps to overshoot ratio; default 0 = no overshoot
23
+ bounce?: number
24
+ }
25
+
26
+ export const DEFAULTS: Required<Omit<SpringConfig, "duration">> & { duration?: number } = {
27
+ frequency: 3,
28
+ dampingRatio: 1,
29
+ epsilon: 0.0005,
30
+ duration: undefined,
31
+ bounce: 0,
32
+ }
33
+
34
+ export function fromDurationBounce(durationSec: number, bounce = 0): { frequency: number; dampingRatio: number } {
35
+ const d = Math.max(0.05, durationSec)
36
+ let zeta: number
37
+ const b = Math.max(0, Math.min(0.999, bounce))
38
+ if (b <= 0) {
39
+ zeta = 1 // critically damped, no overshoot
40
+ } else {
41
+ // Map overshoot ratio M_p=b to damping ratio via standard 2nd-order step response
42
+ const lnMp = Math.log(Math.max(1e-6, b))
43
+ zeta = Math.sqrt((lnMp * lnMp) / (Math.PI * Math.PI + lnMp * lnMp))
44
+ zeta = Math.max(0.02, Math.min(0.999, zeta))
45
+ }
46
+ // Use ~2% settling time approximation Ts ≈ 4/(ζ ω_n)
47
+ const omega = 4 / (Math.max(0.02, zeta) * d)
48
+ const freq = omega / (2 * Math.PI)
49
+ return { frequency: freq, dampingRatio: zeta }
50
+ }
51
+
52
+ /**
53
+ * Advance a 1D damped spring towards its target using a semi-implicit Euler step.
54
+ * - Stable enough for small dt (<= ~1/30s) typical of frame ticks.
55
+ * - Uses frequency/dampingRatio to feel similar across frame rates.
56
+ */
57
+ export function advanceSpring(
58
+ state: SpringState,
59
+ dtSeconds: number,
60
+ config?: SpringConfig,
61
+ ): SpringState & { settled: boolean } {
62
+ let { frequency, dampingRatio, epsilon } = { ...DEFAULTS, ...(config ?? {}) }
63
+ const bounce = config?.bounce ?? DEFAULTS.bounce
64
+ // If duration is provided, derive frequency/dampingRatio from it and bounce
65
+ if (config?.duration != null) {
66
+ const derived = fromDurationBounce(config.duration, config.bounce ?? DEFAULTS.bounce)
67
+ frequency = derived.frequency
68
+ dampingRatio = derived.dampingRatio
69
+ }
70
+ const omega = Math.PI * 2 * Math.max(0.001, frequency)
71
+ const k = omega * omega // stiffness
72
+ const c = 2 * dampingRatio * omega // damping
73
+
74
+ let { x, v, target } = state
75
+ const prevX = x
76
+ if (dtSeconds <= 0)
77
+ return {
78
+ x,
79
+ v,
80
+ target,
81
+ settled: Math.abs(x - target) < epsilon && Math.abs(v) < epsilon,
82
+ }
83
+
84
+ // semi-implicit Euler integration
85
+ const a = k * (target - x) - c * v
86
+ v += a * dtSeconds
87
+ x += v * dtSeconds
88
+
89
+ // With bounce=0 users expect a monotonic approach; clamp if we cross the target.
90
+ if (bounce <= 0) {
91
+ const crossed = (prevX - target) * (x - target) <= 0
92
+ if (crossed) {
93
+ x = target
94
+ v = 0
95
+ }
96
+ }
97
+
98
+ // snap to target when sufficiently close to avoid long tails
99
+ const settled = Math.abs(x - target) < epsilon && Math.abs(v) < epsilon
100
+ if (settled) {
101
+ x = target
102
+ v = 0
103
+ }
104
+ return { x, v, target, settled }
105
+ }
106
+
107
+ /**
108
+ * Internal exact spring step for 1D channel (frame-rate independent).
109
+ * Uses closed-form analytical solution for underdamped/critically-damped/overdamped cases.
110
+ */
111
+ export function stepSpring1D(
112
+ x: number,
113
+ v: number,
114
+ target: number,
115
+ dt: number,
116
+ freq: number,
117
+ zeta: number,
118
+ ): { x: number; v: number } {
119
+ const omega = Math.max(0.001, freq) * 2 * Math.PI
120
+ const A0 = x - target
121
+ const exp = Math.exp(-zeta * omega * dt)
122
+ if (zeta < 1) {
123
+ // Underdamped
124
+ const wd = omega * Math.sqrt(1 - zeta * zeta)
125
+ const cos = Math.cos(wd * dt)
126
+ const sin = Math.sin(wd * dt)
127
+ const B0 = v + zeta * omega * A0
128
+ const A = exp * (A0 * cos + (B0 / wd) * sin)
129
+ const dA = exp * (-zeta * omega * (A0 * cos + (B0 / wd) * sin) + (-A0 * wd * sin + B0 * cos))
130
+ return { x: target + A, v: dA }
131
+ } else if (zeta === 1) {
132
+ // Critically damped
133
+ const t = dt
134
+ const C2 = v + omega * A0
135
+ const A = exp * (A0 + C2 * t)
136
+ const dA = exp * (C2 - omega * (A0 + C2 * t))
137
+ return { x: target + A, v: dA }
138
+ } else {
139
+ // Overdamped
140
+ const s = Math.sqrt(zeta * zeta - 1)
141
+ const r1 = -omega * (zeta - s)
142
+ const r2 = -omega * (zeta + s)
143
+ const C1 = (v - r2 * A0) / (r1 - r2)
144
+ const C2 = A0 - C1
145
+ const e1 = Math.exp(r1 * dt)
146
+ const e2 = Math.exp(r2 * dt)
147
+ const A = C1 * e1 + C2 * e2
148
+ const dA = C1 * r1 * e1 + C2 * r2 * e2
149
+ return { x: target + A, v: dA }
150
+ }
151
+ }
package/src/spring.ts ADDED
@@ -0,0 +1,234 @@
1
+ // spring.ts - Higher-level spring API for number and color values
2
+
3
+ import type { ColorLike, ColorValue } from "./render/surface.js"
4
+ import { parseColor } from "./render/surface.js"
5
+ import { idxToRGB } from "./render/color-utils.js"
6
+ import { advanceSpring, DEFAULTS, type SpringConfig, type SpringState } from "./spring-physics.js"
7
+
8
+ // ---- Types ----
9
+
10
+ export type SpringValue = SpringState & {
11
+ /** last timestamp in ms used for dt; hidden from callers */
12
+ _last?: number
13
+ /** default config for ticks (optional) */
14
+ _config?: SpringConfig
15
+ }
16
+
17
+ export type SpringColorValue = {
18
+ x: { r: number; g: number; b: number }
19
+ v: { r: number; g: number; b: number }
20
+ target: { r: number; g: number; b: number }
21
+ _last?: number
22
+ _config?: SpringConfig
23
+ }
24
+
25
+ export type AnySpringValue = SpringValue | SpringColorValue
26
+
27
+ export type SpringAutoOptions = SpringConfig & {
28
+ as?: "auto" | "number" | "color"
29
+ }
30
+
31
+ // ---- Helpers ----
32
+
33
+ export function colorLikeToRGB(c: ColorLike): { r: number; g: number; b: number } {
34
+ const v = parseColor(c)
35
+ if (typeof v === "number") return idxToRGB(v)
36
+ return { r: v.r | 0, g: v.g | 0, b: v.b | 0 }
37
+ }
38
+
39
+ function isColorSpring(s: AnySpringValue): s is SpringColorValue {
40
+ return typeof (s as any).x === "object"
41
+ }
42
+
43
+ // ---- Number spring primitives ----
44
+
45
+ function initNumber(initial: number, cfg?: SpringConfig): SpringValue {
46
+ return {
47
+ x: initial,
48
+ v: 0,
49
+ target: initial,
50
+ ...(cfg && { _config: cfg }),
51
+ }
52
+ }
53
+
54
+ function tickNumber(s: SpringValue, nowMs: number, cfg?: SpringConfig): SpringValue {
55
+ const last = s._last ?? nowMs
56
+ const dt = Math.max(0, Math.min(0.1, (nowMs - last) / 1000))
57
+ const stepped = advanceSpring(s, dt, cfg ?? s._config)
58
+ return {
59
+ ...s,
60
+ x: stepped.x,
61
+ v: stepped.v,
62
+ target: stepped.target,
63
+ _last: nowMs,
64
+ ...(cfg || s._config ? { _config: cfg ?? s._config } : {}),
65
+ }
66
+ }
67
+
68
+ function toNumber(s: SpringValue, target: number): SpringValue {
69
+ return { ...s, target }
70
+ }
71
+
72
+ function valueNumber(s: SpringValue): number {
73
+ return s.x
74
+ }
75
+
76
+ function settledNumber(s: SpringValue, eps = DEFAULTS.epsilon): boolean {
77
+ return Math.abs(s.x - s.target) < eps && Math.abs(s.v) < eps
78
+ }
79
+
80
+ // ---- Color spring primitives ----
81
+
82
+ const SpringColor = {
83
+ init(initial: ColorLike, cfg?: SpringConfig): SpringColorValue {
84
+ const rgb = colorLikeToRGB(initial)
85
+ return {
86
+ x: { ...rgb },
87
+ v: { r: 0, g: 0, b: 0 },
88
+ target: { ...rgb },
89
+ ...(cfg && { _config: cfg }),
90
+ }
91
+ },
92
+ to(s: SpringColorValue, target: ColorLike): SpringColorValue {
93
+ const rgb = colorLikeToRGB(target)
94
+ return { ...s, target: { ...rgb } }
95
+ },
96
+ tick(s: SpringColorValue, nowMs: number, cfg?: SpringConfig): SpringColorValue {
97
+ const last = s._last ?? nowMs
98
+ const dt = Math.max(0, Math.min(0.1, (nowMs - last) / 1000))
99
+ const rx = advanceSpring({ x: s.x.r, v: s.v.r, target: s.target.r }, dt, cfg ?? s._config)
100
+ const gx = advanceSpring({ x: s.x.g, v: s.v.g, target: s.target.g }, dt, cfg ?? s._config)
101
+ const bx = advanceSpring({ x: s.x.b, v: s.v.b, target: s.target.b }, dt, cfg ?? s._config)
102
+ return {
103
+ x: { r: rx.x, g: gx.x, b: bx.x },
104
+ v: { r: rx.v, g: gx.v, b: bx.v },
105
+ target: { r: s.target.r, g: s.target.g, b: s.target.b },
106
+ _last: nowMs,
107
+ ...(cfg || s._config ? { _config: cfg ?? s._config } : {}),
108
+ }
109
+ },
110
+ value(s: SpringColorValue): ColorValue {
111
+ const clamp = (n: number) => (n < 0 ? 0 : n > 255 ? 255 : n | 0)
112
+ return { r: clamp(s.x.r), g: clamp(s.x.g), b: clamp(s.x.b) }
113
+ },
114
+ settled(s: SpringColorValue, eps = DEFAULTS.epsilon): boolean {
115
+ const dx = Math.abs(s.x.r - s.target.r) + Math.abs(s.x.g - s.target.g) + Math.abs(s.x.b - s.target.b)
116
+ const dv = Math.abs(s.v.r) + Math.abs(s.v.g) + Math.abs(s.v.b)
117
+ return dx < eps * 3 && dv < eps * 3
118
+ },
119
+ }
120
+
121
+ // ---- Unified, type-agnostic helpers ----
122
+
123
+ // Overloads for the ergonomic, single-API surface
124
+ function create(initial: number, cfg?: SpringAutoOptions): SpringValue
125
+ function create(initial: ColorLike, cfg?: SpringAutoOptions): SpringColorValue
126
+ function create(initial: number | ColorLike, cfg?: SpringAutoOptions): AnySpringValue {
127
+ const as = cfg?.as ?? (typeof initial === "number" ? "number" : "color")
128
+ return as === "number" ? initNumber(initial as number, cfg) : SpringColor.init(initial as ColorLike, cfg)
129
+ }
130
+
131
+ function to(s: SpringValue, target: number): SpringValue
132
+ function to(s: SpringColorValue, target: ColorLike): SpringColorValue
133
+ function to(s: AnySpringValue, target: number | ColorLike): AnySpringValue {
134
+ return isColorSpring(s) ? SpringColor.to(s, target as ColorLike) : toNumber(s as SpringValue, target as number)
135
+ }
136
+
137
+ function tick(s: SpringValue, nowMs: number, cfg?: SpringConfig): SpringValue
138
+ function tick(s: SpringColorValue, nowMs: number, cfg?: SpringConfig): SpringColorValue
139
+ function tick(s: AnySpringValue, nowMs: number, cfg?: SpringConfig): AnySpringValue {
140
+ return isColorSpring(s) ? SpringColor.tick(s, nowMs, cfg) : tickNumber(s as SpringValue, nowMs, cfg)
141
+ }
142
+
143
+ function read(s: SpringValue): number
144
+ function read(s: SpringColorValue): ColorValue
145
+ function read(s: AnySpringValue): number | ColorValue {
146
+ return isColorSpring(s) ? SpringColor.value(s) : valueNumber(s as SpringValue)
147
+ }
148
+
149
+ function isSettled(s: SpringValue, eps?: number): boolean
150
+ function isSettled(s: SpringColorValue, eps?: number): boolean
151
+ function isSettled(s: AnySpringValue, eps = DEFAULTS.epsilon): boolean {
152
+ return isColorSpring(s) ? SpringColor.settled(s, eps) : settledNumber(s as SpringValue, eps)
153
+ }
154
+
155
+ // ---- Public Spring API ----
156
+
157
+ export type SpringAPI = {
158
+ // number
159
+ init(initial: number, cfg?: SpringConfig): SpringValue
160
+ to(s: SpringValue, target: number): SpringValue
161
+ tick(s: SpringValue, nowMs: number, cfg?: SpringConfig): SpringValue
162
+ value(s: SpringValue): number
163
+ settled(s: SpringValue, eps?: number): boolean
164
+
165
+ // color (legacy explicit path kept for convenience)
166
+ color: {
167
+ init(initial: ColorLike, cfg?: SpringConfig): SpringColorValue
168
+ to(s: SpringColorValue, target: ColorLike): SpringColorValue
169
+ tick(s: SpringColorValue, nowMs: number, cfg?: SpringConfig): SpringColorValue
170
+ value(s: SpringColorValue): ColorValue
171
+ settled(s: SpringColorValue, eps?: number): boolean
172
+ }
173
+
174
+ // unified ergonomic API (overloads)
175
+ create(initial: number, cfg?: SpringAutoOptions): SpringValue
176
+ create(initial: ColorLike, cfg?: SpringAutoOptions): SpringColorValue
177
+ to(s: SpringColorValue, target: ColorLike): SpringColorValue // overload of number variant
178
+ tick(s: SpringColorValue, nowMs: number, cfg?: SpringConfig): SpringColorValue // overload of number variant
179
+ advance(s: SpringValue, nowMs: number, cfg?: SpringConfig): SpringValue
180
+ advance(s: SpringColorValue, nowMs: number, cfg?: SpringConfig): SpringColorValue
181
+ read(s: SpringValue): number
182
+ read(s: SpringColorValue): ColorValue
183
+ isSettled(s: SpringValue, eps?: number): boolean
184
+ isSettled(s: SpringColorValue, eps?: number): boolean
185
+ }
186
+
187
+ export const Spring: SpringAPI = {
188
+ // number
189
+ init: initNumber,
190
+ to,
191
+ tick,
192
+ value: valueNumber,
193
+ settled: settledNumber,
194
+
195
+ // color (explicit path)
196
+ color: SpringColor,
197
+
198
+ // unified ergonomic API
199
+ create,
200
+ advance: tick,
201
+ read,
202
+ isSettled,
203
+ }
204
+
205
+ // ---- Stepper (frame-based discrete animation) ----
206
+
207
+ export type Stepper = {
208
+ index: number
209
+ length: number
210
+ intervalMs: number
211
+ _accumMs: number
212
+ _last?: number
213
+ }
214
+
215
+ export const Step = {
216
+ init(length: number, intervalMs: number, startIndex = 0): Stepper {
217
+ return {
218
+ index: ((startIndex % length) + length) % length,
219
+ length: Math.max(1, length | 0),
220
+ intervalMs: Math.max(1, intervalMs | 0),
221
+ _accumMs: 0,
222
+ }
223
+ },
224
+ tick(s: Stepper, nowMs: number): Stepper {
225
+ const last = s._last ?? nowMs
226
+ let accum = s._accumMs + Math.max(0, nowMs - last)
227
+ let idx = s.index
228
+ while (accum >= s.intervalMs) {
229
+ accum -= s.intervalMs
230
+ idx = (idx + 1) % s.length
231
+ }
232
+ return { ...s, index: idx, _accumMs: accum, _last: nowMs }
233
+ },
234
+ }