@effect-tui/react 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/dev/Toast.d.ts.map +1 -1
- package/dist/src/dev/Toast.js +24 -15
- package/dist/src/dev/Toast.js.map +1 -1
- package/dist/src/dev.d.ts +20 -1
- package/dist/src/dev.d.ts.map +1 -1
- package/dist/src/dev.js +6 -3
- package/dist/src/dev.js.map +1 -1
- package/dist/src/hooks/index.d.ts +2 -0
- package/dist/src/hooks/index.d.ts.map +1 -1
- package/dist/src/hooks/index.js +1 -0
- package/dist/src/hooks/index.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +4 -2
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/hooks/use-timer.d.ts +57 -0
- package/dist/src/hooks/use-timer.d.ts.map +1 -0
- package/dist/src/hooks/use-timer.js +94 -0
- package/dist/src/hooks/use-timer.js.map +1 -0
- package/dist/src/hosts/base.d.ts +20 -0
- package/dist/src/hosts/base.d.ts.map +1 -1
- package/dist/src/hosts/base.js +104 -0
- package/dist/src/hosts/base.js.map +1 -1
- package/dist/src/hosts/box.d.ts +6 -3
- package/dist/src/hosts/box.d.ts.map +1 -1
- package/dist/src/hosts/box.js +10 -3
- package/dist/src/hosts/box.js.map +1 -1
- package/dist/src/hosts/scroll.d.ts +0 -2
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +3 -6
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/hosts/text.d.ts +5 -2
- package/dist/src/hosts/text.d.ts.map +1 -1
- package/dist/src/hosts/text.js +7 -3
- package/dist/src/hosts/text.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/motion/brands.d.ts +9 -0
- package/dist/src/motion/brands.d.ts.map +1 -0
- package/dist/src/motion/brands.js +9 -0
- package/dist/src/motion/brands.js.map +1 -0
- package/dist/src/motion/color-motion-value.d.ts +4 -0
- package/dist/src/motion/color-motion-value.d.ts.map +1 -1
- package/dist/src/motion/color-motion-value.js +4 -0
- package/dist/src/motion/color-motion-value.js.map +1 -1
- package/dist/src/motion/hooks.d.ts +29 -3
- package/dist/src/motion/hooks.d.ts.map +1 -1
- package/dist/src/motion/hooks.js +40 -4
- package/dist/src/motion/hooks.js.map +1 -1
- package/dist/src/motion/index.d.ts +1 -1
- package/dist/src/motion/index.d.ts.map +1 -1
- package/dist/src/motion/index.js +1 -1
- package/dist/src/motion/index.js.map +1 -1
- package/dist/src/motion/motion-value.d.ts +6 -2
- package/dist/src/motion/motion-value.d.ts.map +1 -1
- package/dist/src/motion/motion-value.js +6 -2
- package/dist/src/motion/motion-value.js.map +1 -1
- package/dist/src/motion/use-sequence.d.ts +10 -2
- package/dist/src/motion/use-sequence.d.ts.map +1 -1
- package/dist/src/motion/use-sequence.js +101 -11
- package/dist/src/motion/use-sequence.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/dev/Toast.tsx +27 -17
- package/src/dev.tsx +19 -14
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-scroll.ts +4 -2
- package/src/hooks/use-timer.ts +155 -0
- package/src/hosts/base.ts +120 -0
- package/src/hosts/box.ts +17 -6
- package/src/hosts/scroll.ts +3 -6
- package/src/hosts/text.ts +13 -5
- package/src/index.ts +2 -0
- package/src/motion/brands.ts +10 -0
- package/src/motion/color-motion-value.ts +6 -0
- package/src/motion/hooks.ts +50 -4
- package/src/motion/index.ts +3 -0
- package/src/motion/motion-value.ts +8 -1
- package/src/motion/use-sequence.ts +113 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "React bindings for @effect-tui/core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"prepublishOnly": "bun run typecheck && bun run build"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@effect-tui/core": "^0.
|
|
86
|
+
"@effect-tui/core": "^0.10.0",
|
|
87
87
|
"@effect/platform": "^0.94.0",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
package/src/dev/Toast.tsx
CHANGED
|
@@ -84,38 +84,48 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|
|
84
84
|
// Screenshot Toast Animation
|
|
85
85
|
// ─────────────────────────────────────────────────────────────
|
|
86
86
|
|
|
87
|
-
//
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
const SUCCESS_COLOR = { r: 30, g: 70, b: 40, a: 1 }
|
|
87
|
+
// Flash: bright background, dark text
|
|
88
|
+
const FLASH_BG = { r: 220, g: 220, b: 220, a: 1 }
|
|
89
|
+
const FLASH_FG = { r: 40, g: 40, b: 40, a: 1 }
|
|
91
90
|
|
|
92
|
-
|
|
91
|
+
// Settled: dark gray background, light text
|
|
92
|
+
const SETTLED_BG = { r: 50, g: 50, b: 55, a: 1 }
|
|
93
|
+
const SETTLED_FG = { r: 200, g: 200, b: 200, a: 1 }
|
|
94
|
+
|
|
95
|
+
function ScreenshotToast({ message, toastId }: { message: string; toastId: number }) {
|
|
93
96
|
// Emoji sequence: 📷 → 📸 → 📷 (camera → flash → camera)
|
|
94
97
|
const emoji = useSequence({
|
|
95
98
|
keyframes: ["📷", "📸", "📷"],
|
|
96
99
|
times: [0, 0.08, 0.2], // Flash at 8%, back at 20%
|
|
97
100
|
duration: 2500,
|
|
101
|
+
playKey: toastId, // Replay when a new toast is created
|
|
98
102
|
})
|
|
99
103
|
|
|
100
|
-
// Background: bright flash → fade to
|
|
101
|
-
|
|
104
|
+
// Background: bright flash → fade to dark gray
|
|
105
|
+
// Pass MotionValue directly to box - hosts auto-subscribe!
|
|
106
|
+
const [bgMv, setBg] = useColorSpring(FLASH_BG, { visualDuration: 0.8, bounce: 0 })
|
|
107
|
+
// Foreground: dark → light (spring animated)
|
|
108
|
+
const [fgMv, setFg] = useColorSpring(FLASH_FG, { visualDuration: 0.8, bounce: 0 })
|
|
102
109
|
|
|
103
|
-
//
|
|
110
|
+
// Reset and trigger animation when toastId changes (new screenshot)
|
|
104
111
|
useEffect(() => {
|
|
112
|
+
// Jump back to flash colors immediately (no animation)
|
|
113
|
+
bgMv.jump(FLASH_BG)
|
|
114
|
+
fgMv.jump(FLASH_FG)
|
|
115
|
+
|
|
116
|
+
// Then spring-animate to settled after flash phase
|
|
105
117
|
const timer = setTimeout(() => {
|
|
106
|
-
setBg(
|
|
107
|
-
|
|
118
|
+
setBg(SETTLED_BG)
|
|
119
|
+
setFg(SETTLED_FG)
|
|
120
|
+
}, 200)
|
|
108
121
|
return () => clearTimeout(timer)
|
|
109
|
-
}, [setBg])
|
|
110
|
-
|
|
111
|
-
const bgValue = bg.get()
|
|
112
|
-
const isFlash = emoji === "📸"
|
|
122
|
+
}, [toastId, bgMv, fgMv, setBg, setFg])
|
|
113
123
|
|
|
114
124
|
return (
|
|
115
125
|
<hstack>
|
|
116
126
|
<spacer />
|
|
117
|
-
<box bg={
|
|
118
|
-
<text fg={
|
|
127
|
+
<box bg={bgMv} padding={{ x: 1 }}>
|
|
128
|
+
<text fg={fgMv}>
|
|
119
129
|
{` ${emoji} ${message} `}
|
|
120
130
|
</text>
|
|
121
131
|
</box>
|
|
@@ -137,7 +147,7 @@ export function ToastContainer() {
|
|
|
137
147
|
|
|
138
148
|
// Special animated toast for screenshots
|
|
139
149
|
if (toast.type === "screenshot") {
|
|
140
|
-
return <ScreenshotToast message={toast.message} />
|
|
150
|
+
return <ScreenshotToast message={toast.message} toastId={toast.id} />
|
|
141
151
|
}
|
|
142
152
|
|
|
143
153
|
const style = TOAST_STYLES[toast.type]
|
package/src/dev.tsx
CHANGED
|
@@ -49,8 +49,8 @@ import type { RendererOptions, TuiRenderer } from "./renderer-types.js"
|
|
|
49
49
|
* }
|
|
50
50
|
* ```
|
|
51
51
|
*/
|
|
52
|
-
export function hmr
|
|
53
|
-
return (self: A): A => {
|
|
52
|
+
export function hmr(key: string): <A,>(self: A) => A {
|
|
53
|
+
return <A,>(self: A): A => {
|
|
54
54
|
return globalValue(Symbol.for(`hmr/${key}`), () => {
|
|
55
55
|
// Apply keepAlive if it's an atom (just sets keepAlive: true)
|
|
56
56
|
if (typeof self === "object" && self !== null && "keepAlive" in self) {
|
|
@@ -169,7 +169,7 @@ export function autoHmr<A>(self: A): A {
|
|
|
169
169
|
if (!location) {
|
|
170
170
|
// Fallback: use a hash of the stack trace
|
|
171
171
|
const fallbackKey = `unknown:${stack.slice(0, 100)}`
|
|
172
|
-
return hmr
|
|
172
|
+
return hmr(fallbackKey)(self)
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
const cacheKey = `${location.file}:${location.line}:${location.col}`
|
|
@@ -189,7 +189,7 @@ export function autoHmr<A>(self: A): A {
|
|
|
189
189
|
keyCache.set(cacheKey, key)
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
return hmr
|
|
192
|
+
return hmr(key)(self)
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
export interface DevRenderOptions extends RendererOptions {
|
|
@@ -309,7 +309,7 @@ function ScreenshotHandler() {
|
|
|
309
309
|
const tmpPath = `/tmp/tui-screenshot-${Date.now()}.txt`
|
|
310
310
|
Bun.write(tmpPath, ansiOutput)
|
|
311
311
|
|
|
312
|
-
show("Screenshot copied!", "
|
|
312
|
+
show("Screenshot copied!", "screenshot", 2500)
|
|
313
313
|
key.preventDefault?.()
|
|
314
314
|
}
|
|
315
315
|
})
|
|
@@ -336,26 +336,31 @@ function StatsOverlay({ sampleMs, title }: { sampleMs?: number; title?: string }
|
|
|
336
336
|
)
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
export interface DevWrapperProps {
|
|
340
|
+
children?: React.ReactNode
|
|
341
|
+
showStats?: boolean
|
|
342
|
+
statsSampleMs?: number
|
|
343
|
+
statsTitle?: string
|
|
344
|
+
mode?: "fullscreen" | "inline"
|
|
345
|
+
}
|
|
346
|
+
|
|
339
347
|
/**
|
|
340
|
-
*
|
|
348
|
+
* Wrapper component that provides dev mode features:
|
|
341
349
|
* - Debug console panel (` backtick to toggle)
|
|
342
350
|
* - Screenshot support (~ tilde) with toast notification
|
|
343
351
|
* - Auto-show console on errors
|
|
344
352
|
* - Optional renderer stats overlay (showStats)
|
|
353
|
+
*
|
|
354
|
+
* Use this to wrap your app when you need dev features but can't use devRender
|
|
355
|
+
* (e.g., when you need to pass props to your App component).
|
|
345
356
|
*/
|
|
346
|
-
function DevWrapper({
|
|
357
|
+
export function DevWrapper({
|
|
347
358
|
children,
|
|
348
359
|
showStats,
|
|
349
360
|
statsSampleMs,
|
|
350
361
|
statsTitle,
|
|
351
362
|
mode,
|
|
352
|
-
}: {
|
|
353
|
-
children?: React.ReactNode
|
|
354
|
-
showStats?: boolean
|
|
355
|
-
statsSampleMs?: number
|
|
356
|
-
statsTitle?: string
|
|
357
|
-
mode?: "fullscreen" | "inline"
|
|
358
|
-
}) {
|
|
363
|
+
}: DevWrapperProps) {
|
|
359
364
|
const { visible } = useConsole({ autoShowOnError: true, initiallyVisible: false })
|
|
360
365
|
|
|
361
366
|
// Inline mode: content flows naturally in vstack
|
package/src/hooks/index.ts
CHANGED
|
@@ -6,3 +6,5 @@ export { usePaste } from "./use-paste.js"
|
|
|
6
6
|
export { useQuit } from "./use-quit.js"
|
|
7
7
|
export type { ScrollState, UseScrollOptions, UseScrollReturn } from "./use-scroll.js"
|
|
8
8
|
export { useScroll } from "./use-scroll.js"
|
|
9
|
+
export type { TimerStatus, TimerType, UseTimerConfig, UseTimerReturn } from "./use-timer.js"
|
|
10
|
+
export { useTimer } from "./use-timer.js"
|
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -362,13 +362,15 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
362
362
|
const itemEnd = itemStart + itemSize
|
|
363
363
|
// Use provided totalSize if available (more accurate than potentially stale contentSize)
|
|
364
364
|
const effectiveContentSize = totalSize ?? contentSize
|
|
365
|
+
const isAbove = itemStart < currentOffset + padding
|
|
366
|
+
const isBelow = itemEnd > currentOffset + currentViewportSize - padding
|
|
365
367
|
|
|
366
368
|
// If item is above viewport, scroll up to show it
|
|
367
|
-
if (
|
|
369
|
+
if (isAbove) {
|
|
368
370
|
setOffset(Math.max(0, itemStart - padding))
|
|
369
371
|
}
|
|
370
372
|
// If item is below viewport, scroll down to show it
|
|
371
|
-
else if (
|
|
373
|
+
else if (isBelow) {
|
|
372
374
|
const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
|
|
373
375
|
setOffset(Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding))
|
|
374
376
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// use-timer.ts — Hook for countdown/stopwatch timers
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Types
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
export type TimerStatus = "RUNNING" | "PAUSED" | "STOPPED"
|
|
10
|
+
export type TimerType = "INCREMENTAL" | "DECREMENTAL"
|
|
11
|
+
|
|
12
|
+
export interface UseTimerConfig {
|
|
13
|
+
/** Starting time value in seconds (required) */
|
|
14
|
+
initialTime: number
|
|
15
|
+
/** Timer direction: 'INCREMENTAL' (stopwatch) or 'DECREMENTAL' (countdown). Default: DECREMENTAL */
|
|
16
|
+
timerType?: TimerType
|
|
17
|
+
/** When to stop. Default: 0 for decremental, null (never) for incremental */
|
|
18
|
+
endTime?: number | null
|
|
19
|
+
/** Auto-start on mount. Default: false */
|
|
20
|
+
autostart?: boolean
|
|
21
|
+
/** Milliseconds between ticks. Default: 1000 */
|
|
22
|
+
interval?: number
|
|
23
|
+
/** Called when timer reaches endTime */
|
|
24
|
+
onTimeOver?: () => void
|
|
25
|
+
/** Called on each tick with current time */
|
|
26
|
+
onTimeUpdate?: (time: number) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UseTimerReturn {
|
|
30
|
+
/** Current time value in seconds */
|
|
31
|
+
time: number
|
|
32
|
+
/** Start or resume the timer */
|
|
33
|
+
start: () => void
|
|
34
|
+
/** Pause the timer */
|
|
35
|
+
pause: () => void
|
|
36
|
+
/** Reset timer to initialTime and stop */
|
|
37
|
+
reset: () => void
|
|
38
|
+
/** Current timer status */
|
|
39
|
+
status: TimerStatus
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Hook
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Hook for countdown or stopwatch timers.
|
|
48
|
+
*
|
|
49
|
+
* @example Countdown timer (rest between sets)
|
|
50
|
+
* ```tsx
|
|
51
|
+
* const { time, start, status } = useTimer({
|
|
52
|
+
* initialTime: 90,
|
|
53
|
+
* timerType: 'DECREMENTAL',
|
|
54
|
+
* onTimeOver: () => playSound()
|
|
55
|
+
* })
|
|
56
|
+
*
|
|
57
|
+
* return <text>Rest: {time}s</text>
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* @example Stopwatch (session duration)
|
|
61
|
+
* ```tsx
|
|
62
|
+
* const { time, start, pause } = useTimer({
|
|
63
|
+
* initialTime: 0,
|
|
64
|
+
* timerType: 'INCREMENTAL',
|
|
65
|
+
* autostart: true
|
|
66
|
+
* })
|
|
67
|
+
*
|
|
68
|
+
* return <text>Duration: {Math.floor(time / 60)}:{(time % 60).toString().padStart(2, '0')}</text>
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export function useTimer(config: UseTimerConfig): UseTimerReturn {
|
|
72
|
+
const {
|
|
73
|
+
initialTime,
|
|
74
|
+
timerType = "DECREMENTAL",
|
|
75
|
+
endTime: endTimeConfig,
|
|
76
|
+
autostart = false,
|
|
77
|
+
interval = 1000,
|
|
78
|
+
onTimeOver,
|
|
79
|
+
onTimeUpdate,
|
|
80
|
+
} = config
|
|
81
|
+
|
|
82
|
+
// Determine endTime based on timer type if not explicitly set
|
|
83
|
+
const endTime = endTimeConfig ?? (timerType === "DECREMENTAL" ? 0 : null)
|
|
84
|
+
|
|
85
|
+
// State
|
|
86
|
+
const [time, setTime] = useState(initialTime)
|
|
87
|
+
const [status, setStatus] = useState<TimerStatus>(autostart ? "RUNNING" : "STOPPED")
|
|
88
|
+
|
|
89
|
+
// Refs for callbacks to avoid stale closures
|
|
90
|
+
const onTimeOverRef = useRef(onTimeOver)
|
|
91
|
+
const onTimeUpdateRef = useRef(onTimeUpdate)
|
|
92
|
+
onTimeOverRef.current = onTimeOver
|
|
93
|
+
onTimeUpdateRef.current = onTimeUpdate
|
|
94
|
+
|
|
95
|
+
// Control functions
|
|
96
|
+
const start = useCallback(() => {
|
|
97
|
+
setStatus("RUNNING")
|
|
98
|
+
}, [])
|
|
99
|
+
|
|
100
|
+
const pause = useCallback(() => {
|
|
101
|
+
setStatus((prev) => (prev === "RUNNING" ? "PAUSED" : prev))
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
const reset = useCallback(() => {
|
|
105
|
+
setTime(initialTime)
|
|
106
|
+
setStatus("STOPPED")
|
|
107
|
+
}, [initialTime])
|
|
108
|
+
|
|
109
|
+
// Timer effect
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (status !== "RUNNING") return
|
|
112
|
+
|
|
113
|
+
const tick = () => {
|
|
114
|
+
setTime((prevTime) => {
|
|
115
|
+
const step = timerType === "DECREMENTAL" ? -1 : 1
|
|
116
|
+
const nextTime = prevTime + step
|
|
117
|
+
|
|
118
|
+
// Check if we've reached the end
|
|
119
|
+
if (endTime !== null) {
|
|
120
|
+
const reachedEnd =
|
|
121
|
+
timerType === "DECREMENTAL" ? nextTime <= endTime : nextTime >= endTime
|
|
122
|
+
|
|
123
|
+
if (reachedEnd) {
|
|
124
|
+
// Schedule callback and status change for next tick to avoid state updates during render
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
setStatus("STOPPED")
|
|
127
|
+
onTimeOverRef.current?.()
|
|
128
|
+
}, 0)
|
|
129
|
+
return endTime
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Call onTimeUpdate
|
|
134
|
+
onTimeUpdateRef.current?.(nextTime)
|
|
135
|
+
return nextTime
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const intervalId = setInterval(tick, interval)
|
|
140
|
+
return () => clearInterval(intervalId)
|
|
141
|
+
}, [status, timerType, endTime, interval])
|
|
142
|
+
|
|
143
|
+
// Reset time when initialTime changes (useful for restarting with different duration)
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
setTime(initialTime)
|
|
146
|
+
}, [initialTime])
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
time,
|
|
150
|
+
start,
|
|
151
|
+
pause,
|
|
152
|
+
reset,
|
|
153
|
+
status,
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/hosts/base.ts
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
import type { CellBuffer, Color, Palette } from "@effect-tui/core"
|
|
2
|
+
import { COLOR_MOTION_VALUE_BRAND, MOTION_VALUE_BRAND } from "../motion/brands.js"
|
|
3
|
+
import type { ColorMotionValue } from "../motion/color-motion-value.js"
|
|
4
|
+
import type { MotionValue } from "../motion/motion-value.js"
|
|
2
5
|
import type { CommonProps, HostContext, HostInstance, Rect, Size } from "../reconciler/types.js"
|
|
3
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Check if a value is a MotionValue.
|
|
9
|
+
* Uses brand Symbol for safety, with duck-typing fallback for compatibility.
|
|
10
|
+
*/
|
|
11
|
+
function isMotionValue(value: unknown): value is MotionValue<unknown> {
|
|
12
|
+
if (typeof value !== "object" || value === null) return false
|
|
13
|
+
// Prefer brand detection (safe, fast)
|
|
14
|
+
if (MOTION_VALUE_BRAND in value) return true
|
|
15
|
+
// Duck-typing fallback for compatibility
|
|
16
|
+
return (
|
|
17
|
+
"get" in value &&
|
|
18
|
+
typeof (value as MotionValue<unknown>).get === "function" &&
|
|
19
|
+
"on" in value &&
|
|
20
|
+
typeof (value as MotionValue<unknown>).on === "function"
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a value is a ColorMotionValue.
|
|
26
|
+
* Uses brand Symbol for safety, with duck-typing fallback for compatibility.
|
|
27
|
+
*/
|
|
28
|
+
function isColorMotionValue(value: unknown): value is ColorMotionValue {
|
|
29
|
+
if (typeof value !== "object" || value === null) return false
|
|
30
|
+
// Prefer brand detection (safe, fast)
|
|
31
|
+
if (COLOR_MOTION_VALUE_BRAND in value) return true
|
|
32
|
+
// Duck-typing fallback for compatibility
|
|
33
|
+
return (
|
|
34
|
+
isMotionValue(value) &&
|
|
35
|
+
"_subscribeChannels" in value &&
|
|
36
|
+
typeof (value as { _subscribeChannels: unknown })._subscribeChannels === "function"
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
4
40
|
/** Host that may have a background color (e.g., BoxHost) */
|
|
5
41
|
export interface HostWithBg extends HostInstance {
|
|
6
42
|
bg?: Color
|
|
@@ -54,6 +90,13 @@ export abstract class BaseHost implements HostInstance {
|
|
|
54
90
|
|
|
55
91
|
protected ctx: HostContext
|
|
56
92
|
|
|
93
|
+
// ─────────────────────────────────────────────────────────────
|
|
94
|
+
// MotionValue subscriptions - for spring-animated props
|
|
95
|
+
// Lazy-initialized to avoid overhead for non-animated hosts
|
|
96
|
+
// Tracks both value identity and unsub function to avoid churn
|
|
97
|
+
// ─────────────────────────────────────────────────────────────
|
|
98
|
+
private _springSubscriptions?: Map<string, { value: unknown; unsub: () => void }>
|
|
99
|
+
|
|
57
100
|
constructor(type: string, _props: CommonProps, ctx: HostContext) {
|
|
58
101
|
this.id = `${type}-${idCounter++}`
|
|
59
102
|
this.type = type
|
|
@@ -63,6 +106,82 @@ export abstract class BaseHost implements HostInstance {
|
|
|
63
106
|
// which would overwrite any values set here.
|
|
64
107
|
}
|
|
65
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Resolve a prop that may be a MotionValue/ColorMotionValue.
|
|
111
|
+
* If it's a spring, subscribes to changes and returns current value.
|
|
112
|
+
* Automatically cleans up old subscriptions when prop changes.
|
|
113
|
+
* Avoids subscription churn by tracking value identity.
|
|
114
|
+
*
|
|
115
|
+
* @param key - Unique key for this prop (used for subscription tracking)
|
|
116
|
+
* @param value - The prop value (may be a MotionValue or regular value)
|
|
117
|
+
* @param onUpdate - Called with new value when spring animates (update the host property!)
|
|
118
|
+
* @returns The resolved value (current spring value or the value itself)
|
|
119
|
+
*/
|
|
120
|
+
protected resolveSpringProp<T>(
|
|
121
|
+
key: string,
|
|
122
|
+
value: T | MotionValue<T> | ColorMotionValue,
|
|
123
|
+
onUpdate?: (value: T) => void,
|
|
124
|
+
): T | undefined {
|
|
125
|
+
const existing = this._springSubscriptions?.get(key)
|
|
126
|
+
|
|
127
|
+
// Not a motion value - clean up any existing subscription and return as-is
|
|
128
|
+
if (!isMotionValue(value)) {
|
|
129
|
+
if (existing) {
|
|
130
|
+
existing.unsub()
|
|
131
|
+
this._springSubscriptions!.delete(key)
|
|
132
|
+
}
|
|
133
|
+
return value as T | undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Same MotionValue as before - no need to resubscribe, just return current value
|
|
137
|
+
if (existing && existing.value === value) {
|
|
138
|
+
return (value as MotionValue<T>).get()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Different MotionValue (or first time) - clean up old and subscribe to new
|
|
142
|
+
if (existing) {
|
|
143
|
+
existing.unsub()
|
|
144
|
+
this._springSubscriptions!.delete(key)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Lazy-init the subscriptions map only when we actually have a spring
|
|
148
|
+
if (!this._springSubscriptions) {
|
|
149
|
+
this._springSubscriptions = new Map()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ColorMotionValue - subscribe to all channels
|
|
153
|
+
if (isColorMotionValue(value)) {
|
|
154
|
+
const mv = value
|
|
155
|
+
const unsub = mv._subscribeChannels(() => {
|
|
156
|
+
onUpdate?.(mv.get() as T) // Update the host's property with new value!
|
|
157
|
+
this.ctx.requestRender()
|
|
158
|
+
})
|
|
159
|
+
this._springSubscriptions.set(key, { value: mv, unsub })
|
|
160
|
+
return mv.get() as T
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Regular MotionValue - subscribe to changes
|
|
164
|
+
const mv = value as MotionValue<T>
|
|
165
|
+
const unsub = mv.on("change", (newValue: T) => {
|
|
166
|
+
onUpdate?.(newValue) // Update the host's property with new value!
|
|
167
|
+
this.ctx.requestRender()
|
|
168
|
+
})
|
|
169
|
+
this._springSubscriptions.set(key, { value: mv, unsub })
|
|
170
|
+
return mv.get()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Clean up all spring subscriptions.
|
|
175
|
+
* Called automatically in destroy().
|
|
176
|
+
*/
|
|
177
|
+
protected clearSpringSubscriptions(): void {
|
|
178
|
+
if (!this._springSubscriptions) return
|
|
179
|
+
for (const { unsub } of this._springSubscriptions.values()) {
|
|
180
|
+
unsub()
|
|
181
|
+
}
|
|
182
|
+
this._springSubscriptions.clear()
|
|
183
|
+
}
|
|
184
|
+
|
|
66
185
|
abstract measure(maxW: number, maxH: number): Size
|
|
67
186
|
abstract render(buffer: CellBuffer, palette: Palette): void
|
|
68
187
|
|
|
@@ -187,6 +306,7 @@ export abstract class BaseHost implements HostInstance {
|
|
|
187
306
|
}
|
|
188
307
|
|
|
189
308
|
destroy(): void {
|
|
309
|
+
this.clearSpringSubscriptions()
|
|
190
310
|
// Override in subclasses if cleanup needed
|
|
191
311
|
}
|
|
192
312
|
|
package/src/hosts/box.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CellBuffer, Color, Palette } from "@effect-tui/core"
|
|
2
2
|
import { Colors } from "@effect-tui/core"
|
|
3
|
+
import type { ColorMotionValue } from "../motion/color-motion-value.js"
|
|
3
4
|
import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
|
|
4
5
|
import {
|
|
5
6
|
type BorderKind,
|
|
@@ -15,15 +16,18 @@ import { SingleChildHost } from "./single-child.js"
|
|
|
15
16
|
|
|
16
17
|
export type { BorderKind }
|
|
17
18
|
|
|
19
|
+
/** Color prop that can be a static Color or a spring-animated ColorMotionValue */
|
|
20
|
+
export type ColorProp = Color | ColorMotionValue
|
|
21
|
+
|
|
18
22
|
export interface BoxProps extends CommonProps {
|
|
19
23
|
padding?: PaddingInput
|
|
20
24
|
border?: BorderKind
|
|
21
|
-
borderColor?:
|
|
22
|
-
bg?:
|
|
25
|
+
borderColor?: ColorProp
|
|
26
|
+
bg?: ColorProp
|
|
23
27
|
/** Title displayed on the top border */
|
|
24
28
|
title?: string
|
|
25
29
|
/** Title text color (defaults to borderColor) */
|
|
26
|
-
titleColor?:
|
|
30
|
+
titleColor?: ColorProp
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export class BoxHost extends SingleChildHost {
|
|
@@ -137,9 +141,16 @@ export class BoxHost extends SingleChildHost {
|
|
|
137
141
|
super.updateProps(props)
|
|
138
142
|
this.padding = resolvePadding(props.padding as BoxProps["padding"])
|
|
139
143
|
this.border = (props.border as BorderKind | undefined) ?? "none"
|
|
140
|
-
|
|
141
|
-
this.
|
|
144
|
+
// Color props support MotionValue/ColorMotionValue - auto-subscribe and animate
|
|
145
|
+
this.borderColor = this.resolveSpringProp("borderColor", props.borderColor, (v) => {
|
|
146
|
+
this.borderColor = v as Color
|
|
147
|
+
}) as Color | undefined
|
|
148
|
+
this.bg = this.resolveSpringProp("bg", props.bg, (v) => {
|
|
149
|
+
this.bg = v as Color
|
|
150
|
+
}) as Color | undefined
|
|
142
151
|
this.title = props.title as string | undefined
|
|
143
|
-
this.titleColor = props.titleColor
|
|
152
|
+
this.titleColor = this.resolveSpringProp("titleColor", props.titleColor, (v) => {
|
|
153
|
+
this.titleColor = v as Color
|
|
154
|
+
}) as Color | undefined
|
|
144
155
|
}
|
|
145
156
|
}
|
package/src/hosts/scroll.ts
CHANGED
|
@@ -52,8 +52,6 @@ export class ScrollHost extends SingleChildHost {
|
|
|
52
52
|
private contentWidth = 0
|
|
53
53
|
private contentHeight = 0
|
|
54
54
|
// Track last reported sizes to avoid redundant callbacks
|
|
55
|
-
private lastViewportW = 0
|
|
56
|
-
private lastViewportH = 0
|
|
57
55
|
private lastReportedContentW = 0
|
|
58
56
|
private lastReportedContentH = 0
|
|
59
57
|
private lastRectX = -1
|
|
@@ -112,10 +110,9 @@ export class ScrollHost extends SingleChildHost {
|
|
|
112
110
|
}
|
|
113
111
|
|
|
114
112
|
// Report viewport size if changed (for useScroll hook)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
this.
|
|
118
|
-
this.onViewportSize?.(rect.w, rect.h)
|
|
113
|
+
// Always report on every layout - React may have recreated the hook with stale refs
|
|
114
|
+
if (this.onViewportSize) {
|
|
115
|
+
this.onViewportSize(rect.w, rect.h)
|
|
119
116
|
}
|
|
120
117
|
|
|
121
118
|
// Report rect if position changed (for hit testing)
|
package/src/hosts/text.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { type CellBuffer, type Color, displayWidth, type Palette } from "@effect-tui/core"
|
|
2
|
+
import type { ColorMotionValue } from "../motion/color-motion-value.js"
|
|
2
3
|
import type { CommonProps, HostContext, Rect, Size } from "../reconciler/types.js"
|
|
3
4
|
import { resolveInheritedBgStyle, styleIdFromProps } from "../utils/index.js"
|
|
4
5
|
import { BaseHost, getInheritedBg } from "./base.js"
|
|
5
6
|
|
|
7
|
+
/** Color prop that can be a static Color or a spring-animated ColorMotionValue */
|
|
8
|
+
export type ColorProp = Color | ColorMotionValue
|
|
9
|
+
|
|
6
10
|
export interface TextProps extends CommonProps {
|
|
7
|
-
fg?:
|
|
8
|
-
bg?:
|
|
11
|
+
fg?: ColorProp
|
|
12
|
+
bg?: ColorProp
|
|
9
13
|
bold?: boolean
|
|
10
14
|
italic?: boolean
|
|
11
15
|
underline?: boolean
|
|
@@ -354,9 +358,13 @@ export class TextHost extends BaseHost {
|
|
|
354
358
|
|
|
355
359
|
override updateProps(props: Record<string, unknown>): void {
|
|
356
360
|
super.updateProps(props)
|
|
357
|
-
//
|
|
358
|
-
this.fg = props.fg
|
|
359
|
-
|
|
361
|
+
// Color props support MotionValue/ColorMotionValue - auto-subscribe and animate
|
|
362
|
+
this.fg = this.resolveSpringProp("fg", props.fg, (v) => {
|
|
363
|
+
this.fg = v as Color
|
|
364
|
+
}) as Color | undefined
|
|
365
|
+
this.bg = this.resolveSpringProp("bg", props.bg, (v) => {
|
|
366
|
+
this.bg = v as Color
|
|
367
|
+
}) as Color | undefined
|
|
360
368
|
this.bold = Boolean(props.bold)
|
|
361
369
|
this.italic = Boolean(props.italic)
|
|
362
370
|
this.underline = Boolean(props.underline)
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand Symbols for MotionValue type detection.
|
|
3
|
+
* Separated to avoid circular dependency issues.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Brand Symbol for safer MotionValue detection */
|
|
7
|
+
export const MOTION_VALUE_BRAND = Symbol.for("effect-tui/MotionValue")
|
|
8
|
+
|
|
9
|
+
/** Brand Symbol for safer ColorMotionValue detection */
|
|
10
|
+
export const COLOR_MOTION_VALUE_BRAND = Symbol.for("effect-tui/ColorMotionValue")
|
|
@@ -2,16 +2,22 @@
|
|
|
2
2
|
* ColorMotionValue - animates RGBA colors using 4 internal numeric springs.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { COLOR_MOTION_VALUE_BRAND } from "./brands.js"
|
|
5
6
|
import { type ColorInput, parseColor, type RGBA } from "./color.js"
|
|
6
7
|
import { EventEmitter } from "./event-emitter.js"
|
|
7
8
|
import { MotionValue } from "./motion-value.js"
|
|
8
9
|
import type { SpringOptions } from "./types.js"
|
|
9
10
|
|
|
11
|
+
export { COLOR_MOTION_VALUE_BRAND } from "./brands.js"
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* ColorMotionValue animates RGBA colors using 4 internal numeric springs.
|
|
12
15
|
* Each channel (r, g, b, a) is animated independently with the same spring config.
|
|
13
16
|
*/
|
|
14
17
|
export class ColorMotionValue extends EventEmitter<RGBA> {
|
|
18
|
+
/** @internal Brand for safe type detection */
|
|
19
|
+
readonly [COLOR_MOTION_VALUE_BRAND] = true
|
|
20
|
+
|
|
15
21
|
private rMv: MotionValue<number>
|
|
16
22
|
private gMv: MotionValue<number>
|
|
17
23
|
private bMv: MotionValue<number>
|