@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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/anim.d.ts +4 -0
- package/dist/anim.d.ts.map +1 -0
- package/dist/anim.js +5 -0
- package/dist/anim.js.map +1 -0
- package/dist/ansi.d.ts +69 -0
- package/dist/ansi.d.ts.map +1 -0
- package/dist/ansi.js +72 -0
- package/dist/ansi.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +18 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +247 -0
- package/dist/keys.js.map +1 -0
- package/dist/layout/linearStack.d.ts +17 -0
- package/dist/layout/linearStack.d.ts.map +1 -0
- package/dist/layout/linearStack.js +86 -0
- package/dist/layout/linearStack.js.map +1 -0
- package/dist/motion-value.d.ts +58 -0
- package/dist/motion-value.d.ts.map +1 -0
- package/dist/motion-value.js +250 -0
- package/dist/motion-value.js.map +1 -0
- package/dist/present/display.d.ts +58 -0
- package/dist/present/display.d.ts.map +1 -0
- package/dist/present/display.js +168 -0
- package/dist/present/display.js.map +1 -0
- package/dist/present/writers/fullscreen.d.ts +19 -0
- package/dist/present/writers/fullscreen.d.ts.map +1 -0
- package/dist/present/writers/fullscreen.js +55 -0
- package/dist/present/writers/fullscreen.js.map +1 -0
- package/dist/present/writers/inline.d.ts +20 -0
- package/dist/present/writers/inline.d.ts.map +1 -0
- package/dist/present/writers/inline.js +92 -0
- package/dist/present/writers/inline.js.map +1 -0
- package/dist/render/buffer.d.ts +31 -0
- package/dist/render/buffer.d.ts.map +1 -0
- package/dist/render/buffer.js +183 -0
- package/dist/render/buffer.js.map +1 -0
- package/dist/render/color-utils.d.ts +18 -0
- package/dist/render/color-utils.d.ts.map +1 -0
- package/dist/render/color-utils.js +58 -0
- package/dist/render/color-utils.js.map +1 -0
- package/dist/render/diff.d.ts +30 -0
- package/dist/render/diff.d.ts.map +1 -0
- package/dist/render/diff.js +83 -0
- package/dist/render/diff.js.map +1 -0
- package/dist/render/measure.d.ts +15 -0
- package/dist/render/measure.d.ts.map +1 -0
- package/dist/render/measure.js +65 -0
- package/dist/render/measure.js.map +1 -0
- package/dist/render/palette.d.ts +46 -0
- package/dist/render/palette.d.ts.map +1 -0
- package/dist/render/palette.js +108 -0
- package/dist/render/palette.js.map +1 -0
- package/dist/render/surface.d.ts +77 -0
- package/dist/render/surface.d.ts.map +1 -0
- package/dist/render/surface.js +198 -0
- package/dist/render/surface.js.map +1 -0
- package/dist/runtime/backend_node.d.ts +36 -0
- package/dist/runtime/backend_node.d.ts.map +1 -0
- package/dist/runtime/backend_node.js +66 -0
- package/dist/runtime/backend_node.js.map +1 -0
- package/dist/spring-physics.d.ts +36 -0
- package/dist/spring-physics.d.ts.map +1 -0
- package/dist/spring-physics.js +113 -0
- package/dist/spring-physics.js.map +1 -0
- package/dist/spring.d.ts +73 -0
- package/dist/spring.d.ts.map +1 -0
- package/dist/spring.js +136 -0
- package/dist/spring.js.map +1 -0
- package/dist/ui/containers/canvas.d.ts +13 -0
- package/dist/ui/containers/canvas.d.ts.map +1 -0
- package/dist/ui/containers/canvas.js +16 -0
- package/dist/ui/containers/canvas.js.map +1 -0
- package/dist/ui/containers/geometry-reader.d.ts +17 -0
- package/dist/ui/containers/geometry-reader.d.ts.map +1 -0
- package/dist/ui/containers/geometry-reader.js +24 -0
- package/dist/ui/containers/geometry-reader.js.map +1 -0
- package/dist/ui/containers/hstack.d.ts +12 -0
- package/dist/ui/containers/hstack.d.ts.map +1 -0
- package/dist/ui/containers/hstack.js +28 -0
- package/dist/ui/containers/hstack.js.map +1 -0
- package/dist/ui/containers/scroll.d.ts +28 -0
- package/dist/ui/containers/scroll.d.ts.map +1 -0
- package/dist/ui/containers/scroll.js +97 -0
- package/dist/ui/containers/scroll.js.map +1 -0
- package/dist/ui/containers/shared.d.ts +12 -0
- package/dist/ui/containers/shared.d.ts.map +1 -0
- package/dist/ui/containers/shared.js +19 -0
- package/dist/ui/containers/shared.js.map +1 -0
- package/dist/ui/containers/vstack.d.ts +12 -0
- package/dist/ui/containers/vstack.d.ts.map +1 -0
- package/dist/ui/containers/vstack.js +28 -0
- package/dist/ui/containers/vstack.js.map +1 -0
- package/dist/ui/containers/zstack.d.ts +14 -0
- package/dist/ui/containers/zstack.d.ts.map +1 -0
- package/dist/ui/containers/zstack.js +36 -0
- package/dist/ui/containers/zstack.js.map +1 -0
- package/dist/ui/core/geometry-store.d.ts +22 -0
- package/dist/ui/core/geometry-store.d.ts.map +1 -0
- package/dist/ui/core/geometry-store.js +29 -0
- package/dist/ui/core/geometry-store.js.map +1 -0
- package/dist/ui/core/geometry.d.ts +34 -0
- package/dist/ui/core/geometry.d.ts.map +1 -0
- package/dist/ui/core/geometry.js +14 -0
- package/dist/ui/core/geometry.js.map +1 -0
- package/dist/ui/core/view.d.ts +25 -0
- package/dist/ui/core/view.d.ts.map +1 -0
- package/dist/ui/core/view.js +34 -0
- package/dist/ui/core/view.js.map +1 -0
- package/dist/ui/index.d.ts +44 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +39 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/inlinetext.d.ts +24 -0
- package/dist/ui/inlinetext.d.ts.map +1 -0
- package/dist/ui/inlinetext.js +131 -0
- package/dist/ui/inlinetext.js.map +1 -0
- package/dist/ui/install.d.ts +22 -0
- package/dist/ui/install.d.ts.map +1 -0
- package/dist/ui/install.js +66 -0
- package/dist/ui/install.js.map +1 -0
- package/dist/ui/markdown.d.ts +40 -0
- package/dist/ui/markdown.d.ts.map +1 -0
- package/dist/ui/markdown.js +351 -0
- package/dist/ui/markdown.js.map +1 -0
- package/dist/ui/modifiers/border.d.ts +33 -0
- package/dist/ui/modifiers/border.d.ts.map +1 -0
- package/dist/ui/modifiers/border.js +82 -0
- package/dist/ui/modifiers/border.js.map +1 -0
- package/dist/ui/modifiers/fill.d.ts +14 -0
- package/dist/ui/modifiers/fill.d.ts.map +1 -0
- package/dist/ui/modifiers/fill.js +25 -0
- package/dist/ui/modifiers/fill.js.map +1 -0
- package/dist/ui/modifiers/frame.d.ts +23 -0
- package/dist/ui/modifiers/frame.d.ts.map +1 -0
- package/dist/ui/modifiers/frame.js +54 -0
- package/dist/ui/modifiers/frame.js.map +1 -0
- package/dist/ui/modifiers/offset.d.ts +15 -0
- package/dist/ui/modifiers/offset.d.ts.map +1 -0
- package/dist/ui/modifiers/offset.js +21 -0
- package/dist/ui/modifiers/offset.js.map +1 -0
- package/dist/ui/modifiers/opacity.d.ts +15 -0
- package/dist/ui/modifiers/opacity.d.ts.map +1 -0
- package/dist/ui/modifiers/opacity.js +95 -0
- package/dist/ui/modifiers/opacity.js.map +1 -0
- package/dist/ui/modifiers/padding.d.ts +20 -0
- package/dist/ui/modifiers/padding.d.ts.map +1 -0
- package/dist/ui/modifiers/padding.js +36 -0
- package/dist/ui/modifiers/padding.js.map +1 -0
- package/dist/ui/modifiers/styled.d.ts +14 -0
- package/dist/ui/modifiers/styled.d.ts.map +1 -0
- package/dist/ui/modifiers/styled.js +26 -0
- package/dist/ui/modifiers/styled.js.map +1 -0
- package/dist/ui/primitives/rectangle.d.ts +15 -0
- package/dist/ui/primitives/rectangle.d.ts.map +1 -0
- package/dist/ui/primitives/rectangle.js +23 -0
- package/dist/ui/primitives/rectangle.js.map +1 -0
- package/dist/ui/primitives/spacer.d.ts +13 -0
- package/dist/ui/primitives/spacer.d.ts.map +1 -0
- package/dist/ui/primitives/spacer.js +16 -0
- package/dist/ui/primitives/spacer.js.map +1 -0
- package/dist/ui/primitives/text.d.ts +15 -0
- package/dist/ui/primitives/text.d.ts.map +1 -0
- package/dist/ui/primitives/text.js +79 -0
- package/dist/ui/primitives/text.js.map +1 -0
- package/dist/ui/primitives/wrapped-text.d.ts +30 -0
- package/dist/ui/primitives/wrapped-text.d.ts.map +1 -0
- package/dist/ui/primitives/wrapped-text.js +117 -0
- package/dist/ui/primitives/wrapped-text.js.map +1 -0
- package/dist/ui/shinytext.d.ts +66 -0
- package/dist/ui/shinytext.d.ts.map +1 -0
- package/dist/ui/shinytext.js +99 -0
- package/dist/ui/shinytext.js.map +1 -0
- package/dist/ui/text/layout.d.ts +35 -0
- package/dist/ui/text/layout.d.ts.map +1 -0
- package/dist/ui/text/layout.js +102 -0
- package/dist/ui/text/layout.js.map +1 -0
- package/dist/ui/textinput.d.ts +140 -0
- package/dist/ui/textinput.d.ts.map +1 -0
- package/dist/ui/textinput.js +402 -0
- package/dist/ui/textinput.js.map +1 -0
- package/dist/ui/view-constructors.d.ts +72 -0
- package/dist/ui/view-constructors.d.ts.map +1 -0
- package/dist/ui/view-constructors.js +74 -0
- package/dist/ui/view-constructors.js.map +1 -0
- package/package.json +57 -0
- package/src/anim.ts +5 -0
- package/src/ansi.ts +83 -0
- package/src/index.ts +21 -0
- package/src/keys.ts +302 -0
- package/src/layout/linearStack.ts +115 -0
- package/src/motion-value.ts +335 -0
- package/src/present/display.ts +206 -0
- package/src/present/writers/fullscreen.ts +58 -0
- package/src/present/writers/inline.ts +101 -0
- package/src/render/buffer.ts +200 -0
- package/src/render/color-utils.ts +60 -0
- package/src/render/diff.ts +95 -0
- package/src/render/measure.ts +74 -0
- package/src/render/palette.ts +113 -0
- package/src/render/surface.ts +238 -0
- package/src/runtime/backend_node.ts +80 -0
- package/src/spring-physics.ts +151 -0
- package/src/spring.ts +234 -0
- package/src/ui/__snapshots__/wrappedtext.test.ts.snap +57 -0
- package/src/ui/containers/canvas.ts +18 -0
- package/src/ui/containers/geometry-reader.ts +32 -0
- package/src/ui/containers/hstack.ts +33 -0
- package/src/ui/containers/scroll.ts +106 -0
- package/src/ui/containers/shared.ts +27 -0
- package/src/ui/containers/vstack.ts +34 -0
- package/src/ui/containers/zstack.ts +37 -0
- package/src/ui/core/geometry-store.ts +42 -0
- package/src/ui/core/geometry.ts +30 -0
- package/src/ui/core/view.ts +49 -0
- package/src/ui/index.ts +84 -0
- package/src/ui/inlinetext.ts +135 -0
- package/src/ui/install.ts +110 -0
- package/src/ui/markdown.test.ts +74 -0
- package/src/ui/markdown.ts +388 -0
- package/src/ui/modifiers/border.ts +100 -0
- package/src/ui/modifiers/fill.ts +28 -0
- package/src/ui/modifiers/frame.ts +74 -0
- package/src/ui/modifiers/offset.ts +23 -0
- package/src/ui/modifiers/opacity.ts +93 -0
- package/src/ui/modifiers/padding.ts +53 -0
- package/src/ui/modifiers/styled.ts +31 -0
- package/src/ui/primitives/rectangle.ts +25 -0
- package/src/ui/primitives/spacer.ts +18 -0
- package/src/ui/primitives/text.ts +85 -0
- package/src/ui/primitives/wrapped-text.ts +131 -0
- package/src/ui/shinytext.ts +159 -0
- package/src/ui/text/layout.ts +119 -0
- package/src/ui/textinput.ts +496 -0
- package/src/ui/view-constructors.ts +96 -0
- 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
|
+
}
|