@effect-tui/react 0.9.3 → 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 +1 -1
- package/dist/src/dev.d.ts.map +1 -1
- 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 +0 -8
- 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 -7
- 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/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 +4 -4
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-scroll.ts +0 -8
- 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 -7
- package/src/hosts/text.ts +13 -5
- 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 {
|
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
|
@@ -289,7 +289,6 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
289
289
|
const handleViewportSize = useCallback(
|
|
290
290
|
(width: number, height: number) => {
|
|
291
291
|
const newSize = axis === "vertical" ? height : width
|
|
292
|
-
console.log("[useScroll] viewport size reported", { width, height, axis, newSize })
|
|
293
292
|
// Sync ref immediately so scrollToVisible uses correct size
|
|
294
293
|
viewportSizeRef.current = newSize
|
|
295
294
|
setViewportSize(newSize)
|
|
@@ -365,13 +364,6 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
365
364
|
const effectiveContentSize = totalSize ?? contentSize
|
|
366
365
|
const isAbove = itemStart < currentOffset + padding
|
|
367
366
|
const isBelow = itemEnd > currentOffset + currentViewportSize - padding
|
|
368
|
-
console.log("[useScroll] scrollToVisible", {
|
|
369
|
-
currentViewportSize,
|
|
370
|
-
itemEnd,
|
|
371
|
-
currentOffset,
|
|
372
|
-
isAbove,
|
|
373
|
-
isBelow,
|
|
374
|
-
})
|
|
375
367
|
|
|
376
368
|
// If item is above viewport, scroll up to show it
|
|
377
369
|
if (isAbove) {
|
|
@@ -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
|
|
@@ -103,7 +101,6 @@ export class ScrollHost extends SingleChildHost {
|
|
|
103
101
|
|
|
104
102
|
override layout(rect: Rect): void {
|
|
105
103
|
super.layout(rect)
|
|
106
|
-
console.log("[ScrollHost] layout rect", { x: rect.x, y: rect.y, w: rect.w, h: rect.h })
|
|
107
104
|
|
|
108
105
|
// Report content size if changed (deferred from measure() to keep it pure)
|
|
109
106
|
if (this.contentWidth !== this.lastReportedContentW || this.contentHeight !== this.lastReportedContentH) {
|
|
@@ -113,10 +110,9 @@ export class ScrollHost extends SingleChildHost {
|
|
|
113
110
|
}
|
|
114
111
|
|
|
115
112
|
// Report viewport size if changed (for useScroll hook)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
this.
|
|
119
|
-
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)
|
|
120
116
|
}
|
|
121
117
|
|
|
122
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)
|
|
@@ -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>
|
package/src/motion/hooks.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* React hooks for spring animations.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { useCallback, useEffect, useRef } from "react"
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react"
|
|
6
6
|
import { useRenderer } from "../renderer.js"
|
|
7
7
|
import type { ColorInput } from "./color.js"
|
|
8
8
|
import { ColorMotionValue } from "./color-motion-value.js"
|
|
@@ -211,13 +211,18 @@ export function useColorMotionValue(initial: ColorInput): ColorMotionValue {
|
|
|
211
211
|
/**
|
|
212
212
|
* Create a spring-animated color. Accepts hex, rgb(), hsl(), or {r,g,b} object.
|
|
213
213
|
*
|
|
214
|
+
* Returns a MotionValue - best for <canvas draw={...}> where you read in callbacks.
|
|
215
|
+
* For JSX props, use useColorSpringState instead.
|
|
216
|
+
*
|
|
214
217
|
* @example
|
|
215
218
|
* const [colorMv, setColor] = useColorSpring("#ff0000", { visualDuration: 0.5 })
|
|
216
219
|
* setColor("#00ff00") // Spring to green
|
|
217
|
-
* setColor("hsl(240, 100%, 50%)") // Spring to blue
|
|
218
220
|
*
|
|
219
|
-
* // In draw callback
|
|
220
|
-
*
|
|
221
|
+
* // In canvas draw callback
|
|
222
|
+
* <canvas draw={(ctx) => {
|
|
223
|
+
* const { r, g, b } = colorMv.get()
|
|
224
|
+
* ctx.fill(0, 0, 10, 5, "█", { fg: Colors.rgb(r, g, b) })
|
|
225
|
+
* }} />
|
|
221
226
|
*/
|
|
222
227
|
export function useColorSpring(
|
|
223
228
|
initial: ColorInput,
|
|
@@ -243,3 +248,44 @@ export function useColorSpring(
|
|
|
243
248
|
|
|
244
249
|
return [mv, set]
|
|
245
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Like useColorSpring, but returns React state that updates during animation.
|
|
254
|
+
* Use this when you need spring values in JSX props.
|
|
255
|
+
*
|
|
256
|
+
* For <canvas draw={...}> callbacks, use useColorSpring instead (better perf).
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* const [color, setColor] = useColorSpringState("#ff0000", { visualDuration: 0.5 })
|
|
260
|
+
* setColor("#00ff00") // Spring to green
|
|
261
|
+
*
|
|
262
|
+
* // In JSX - React re-renders as the spring animates
|
|
263
|
+
* <box bg={Colors.rgb(color.r, color.g, color.b)}>
|
|
264
|
+
* <text>Hello</text>
|
|
265
|
+
* </box>
|
|
266
|
+
*/
|
|
267
|
+
export function useColorSpringState(
|
|
268
|
+
initial: ColorInput,
|
|
269
|
+
options?: SpringOptions,
|
|
270
|
+
): [{ r: number; g: number; b: number; a: number }, (color: ColorInput) => void] {
|
|
271
|
+
const mv = useColorMotionValue(initial)
|
|
272
|
+
const [value, setValue] = useState(() => mv.get())
|
|
273
|
+
const optionsRef = useRef(options)
|
|
274
|
+
optionsRef.current = options
|
|
275
|
+
|
|
276
|
+
// Subscribe to spring changes and update React state
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
return mv._subscribeChannels(() => {
|
|
279
|
+
setValue(mv.get())
|
|
280
|
+
})
|
|
281
|
+
}, [mv])
|
|
282
|
+
|
|
283
|
+
const set = useCallback(
|
|
284
|
+
(color: ColorInput) => {
|
|
285
|
+
mv.set(color, optionsRef.current)
|
|
286
|
+
},
|
|
287
|
+
[mv],
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return [value, set]
|
|
291
|
+
}
|