@effect-tui/react 0.9.3 → 0.10.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/dist/src/components/ListView.d.ts.map +1 -1
- package/dist/src/components/ListView.js +7 -2
- package/dist/src/components/ListView.js.map +1 -1
- 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.js +12 -12
- 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 +28 -0
- package/dist/src/hosts/base.d.ts.map +1 -1
- package/dist/src/hosts/base.js +118 -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/index.d.ts +2 -2
- 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/src/reconciler/types.d.ts +24 -1
- package/dist/src/reconciler/types.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/ListView.tsx +8 -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 +12 -12
- package/src/hooks/use-timer.ts +155 -0
- package/src/hosts/base.ts +137 -0
- package/src/hosts/box.ts +17 -6
- package/src/hosts/scroll.ts +3 -7
- package/src/hosts/text.ts +13 -5
- package/src/index.ts +5 -1
- 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/src/reconciler/types.ts +21 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-tui/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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.1",
|
|
87
87
|
"@effect/platform": "^0.94.0",
|
|
88
88
|
"@effect/platform-bun": "^0.87.0",
|
|
89
89
|
"@effect/rpc": "^0.73.0",
|
|
@@ -123,12 +123,18 @@ export function ListView<T>({
|
|
|
123
123
|
|
|
124
124
|
// Track previous selection to detect changes
|
|
125
125
|
const prevSelectedRef = useRef(selectedIndex)
|
|
126
|
+
// Track previous scrollToVisible reference to detect viewport changes
|
|
127
|
+
const prevScrollToVisibleRef = useRef(scrollToVisible)
|
|
126
128
|
|
|
127
|
-
// Scroll to keep selection visible when it changes
|
|
129
|
+
// Scroll to keep selection visible when it changes OR when viewport size changes
|
|
128
130
|
// useLayoutEffect runs before paint - prevents visible "jump" when navigating
|
|
129
131
|
useLayoutEffect(() => {
|
|
130
|
-
|
|
132
|
+
const selectionChanged = selectedIndex !== prevSelectedRef.current
|
|
133
|
+
const viewportChanged = scrollToVisible !== prevScrollToVisibleRef.current
|
|
134
|
+
|
|
135
|
+
if (selectionChanged || viewportChanged) {
|
|
131
136
|
prevSelectedRef.current = selectedIndex
|
|
137
|
+
prevScrollToVisibleRef.current = scrollToVisible
|
|
132
138
|
// Pass totalHeight to avoid stale contentSize issues when jumping to end
|
|
133
139
|
scrollToVisible(selectedIndex, itemHeight, scrollPadding, totalHeight)
|
|
134
140
|
}
|
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)
|
|
@@ -354,7 +353,9 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
354
353
|
useKeyboard(handleKey)
|
|
355
354
|
|
|
356
355
|
// Scroll to make a position visible (for keeping selection in view)
|
|
357
|
-
// Uses refs to avoid stale closures -
|
|
356
|
+
// Uses refs to avoid stale closures, but re-creates when viewport size changes
|
|
357
|
+
// so selection effects can re-run after measurement updates.
|
|
358
|
+
// Bypasses clampOffset because it uses totalSize for accurate clamping
|
|
358
359
|
const scrollToVisible = useCallback(
|
|
359
360
|
(position: number, itemSize = 1, padding = 0, totalSize?: number) => {
|
|
360
361
|
const currentOffset = offsetRef.current
|
|
@@ -365,25 +366,24 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
|
|
|
365
366
|
const effectiveContentSize = totalSize ?? contentSize
|
|
366
367
|
const isAbove = itemStart < currentOffset + padding
|
|
367
368
|
const isBelow = itemEnd > currentOffset + currentViewportSize - padding
|
|
368
|
-
console.log("[useScroll] scrollToVisible", {
|
|
369
|
-
currentViewportSize,
|
|
370
|
-
itemEnd,
|
|
371
|
-
currentOffset,
|
|
372
|
-
isAbove,
|
|
373
|
-
isBelow,
|
|
374
|
-
})
|
|
375
369
|
|
|
376
370
|
// If item is above viewport, scroll up to show it
|
|
377
371
|
if (isAbove) {
|
|
378
|
-
|
|
372
|
+
const newOffset = Math.max(0, itemStart - padding)
|
|
373
|
+
offsetRef.current = newOffset
|
|
374
|
+
wasAtEndRef.current = false
|
|
375
|
+
setOffsetRaw(newOffset)
|
|
379
376
|
}
|
|
380
377
|
// If item is below viewport, scroll down to show it
|
|
381
378
|
else if (isBelow) {
|
|
382
379
|
const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
|
|
383
|
-
|
|
380
|
+
const newOffset = Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding)
|
|
381
|
+
offsetRef.current = newOffset
|
|
382
|
+
wasAtEndRef.current = newOffset >= currentMaxOffset - 1
|
|
383
|
+
setOffsetRaw(newOffset)
|
|
384
384
|
}
|
|
385
385
|
},
|
|
386
|
-
[contentSize,
|
|
386
|
+
[contentSize, viewportSize],
|
|
387
387
|
)
|
|
388
388
|
|
|
389
389
|
const state: ScrollState = {
|
|
@@ -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
|
|
@@ -52,8 +88,22 @@ export abstract class BaseHost implements HostInstance {
|
|
|
52
88
|
/** @internal Marks this node as static content (for Static component) */
|
|
53
89
|
__static?: boolean
|
|
54
90
|
|
|
91
|
+
// ─────────────────────────────────────────────────────────────
|
|
92
|
+
// onLayout callback - fires when layout size changes
|
|
93
|
+
// ─────────────────────────────────────────────────────────────
|
|
94
|
+
onLayout?: (size: { width: number; height: number; x: number; y: number }) => void
|
|
95
|
+
private _lastLayoutW = -1
|
|
96
|
+
private _lastLayoutH = -1
|
|
97
|
+
|
|
55
98
|
protected ctx: HostContext
|
|
56
99
|
|
|
100
|
+
// ─────────────────────────────────────────────────────────────
|
|
101
|
+
// MotionValue subscriptions - for spring-animated props
|
|
102
|
+
// Lazy-initialized to avoid overhead for non-animated hosts
|
|
103
|
+
// Tracks both value identity and unsub function to avoid churn
|
|
104
|
+
// ─────────────────────────────────────────────────────────────
|
|
105
|
+
private _springSubscriptions?: Map<string, { value: unknown; unsub: () => void }>
|
|
106
|
+
|
|
57
107
|
constructor(type: string, _props: CommonProps, ctx: HostContext) {
|
|
58
108
|
this.id = `${type}-${idCounter++}`
|
|
59
109
|
this.type = type
|
|
@@ -63,6 +113,82 @@ export abstract class BaseHost implements HostInstance {
|
|
|
63
113
|
// which would overwrite any values set here.
|
|
64
114
|
}
|
|
65
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Resolve a prop that may be a MotionValue/ColorMotionValue.
|
|
118
|
+
* If it's a spring, subscribes to changes and returns current value.
|
|
119
|
+
* Automatically cleans up old subscriptions when prop changes.
|
|
120
|
+
* Avoids subscription churn by tracking value identity.
|
|
121
|
+
*
|
|
122
|
+
* @param key - Unique key for this prop (used for subscription tracking)
|
|
123
|
+
* @param value - The prop value (may be a MotionValue or regular value)
|
|
124
|
+
* @param onUpdate - Called with new value when spring animates (update the host property!)
|
|
125
|
+
* @returns The resolved value (current spring value or the value itself)
|
|
126
|
+
*/
|
|
127
|
+
protected resolveSpringProp<T>(
|
|
128
|
+
key: string,
|
|
129
|
+
value: T | MotionValue<T> | ColorMotionValue,
|
|
130
|
+
onUpdate?: (value: T) => void,
|
|
131
|
+
): T | undefined {
|
|
132
|
+
const existing = this._springSubscriptions?.get(key)
|
|
133
|
+
|
|
134
|
+
// Not a motion value - clean up any existing subscription and return as-is
|
|
135
|
+
if (!isMotionValue(value)) {
|
|
136
|
+
if (existing) {
|
|
137
|
+
existing.unsub()
|
|
138
|
+
this._springSubscriptions!.delete(key)
|
|
139
|
+
}
|
|
140
|
+
return value as T | undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Same MotionValue as before - no need to resubscribe, just return current value
|
|
144
|
+
if (existing && existing.value === value) {
|
|
145
|
+
return (value as MotionValue<T>).get()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Different MotionValue (or first time) - clean up old and subscribe to new
|
|
149
|
+
if (existing) {
|
|
150
|
+
existing.unsub()
|
|
151
|
+
this._springSubscriptions!.delete(key)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Lazy-init the subscriptions map only when we actually have a spring
|
|
155
|
+
if (!this._springSubscriptions) {
|
|
156
|
+
this._springSubscriptions = new Map()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ColorMotionValue - subscribe to all channels
|
|
160
|
+
if (isColorMotionValue(value)) {
|
|
161
|
+
const mv = value
|
|
162
|
+
const unsub = mv._subscribeChannels(() => {
|
|
163
|
+
onUpdate?.(mv.get() as T) // Update the host's property with new value!
|
|
164
|
+
this.ctx.requestRender()
|
|
165
|
+
})
|
|
166
|
+
this._springSubscriptions.set(key, { value: mv, unsub })
|
|
167
|
+
return mv.get() as T
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Regular MotionValue - subscribe to changes
|
|
171
|
+
const mv = value as MotionValue<T>
|
|
172
|
+
const unsub = mv.on("change", (newValue: T) => {
|
|
173
|
+
onUpdate?.(newValue) // Update the host's property with new value!
|
|
174
|
+
this.ctx.requestRender()
|
|
175
|
+
})
|
|
176
|
+
this._springSubscriptions.set(key, { value: mv, unsub })
|
|
177
|
+
return mv.get()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Clean up all spring subscriptions.
|
|
182
|
+
* Called automatically in destroy().
|
|
183
|
+
*/
|
|
184
|
+
protected clearSpringSubscriptions(): void {
|
|
185
|
+
if (!this._springSubscriptions) return
|
|
186
|
+
for (const { unsub } of this._springSubscriptions.values()) {
|
|
187
|
+
unsub()
|
|
188
|
+
}
|
|
189
|
+
this._springSubscriptions.clear()
|
|
190
|
+
}
|
|
191
|
+
|
|
66
192
|
abstract measure(maxW: number, maxH: number): Size
|
|
67
193
|
abstract render(buffer: CellBuffer, palette: Palette): void
|
|
68
194
|
|
|
@@ -81,6 +207,13 @@ export abstract class BaseHost implements HostInstance {
|
|
|
81
207
|
if (this.frameMaxHeight !== undefined) h = Math.min(this.frameMaxHeight, h)
|
|
82
208
|
|
|
83
209
|
this.rect = { x: rect.x, y: rect.y, w, h }
|
|
210
|
+
|
|
211
|
+
// Fire onLayout callback if size changed (deduplicated)
|
|
212
|
+
if (this.onLayout && (w !== this._lastLayoutW || h !== this._lastLayoutH)) {
|
|
213
|
+
this._lastLayoutW = w
|
|
214
|
+
this._lastLayoutH = h
|
|
215
|
+
this.onLayout({ width: w, height: h, x: rect.x, y: rect.y })
|
|
216
|
+
}
|
|
84
217
|
}
|
|
85
218
|
|
|
86
219
|
/**
|
|
@@ -184,9 +317,13 @@ export abstract class BaseHost implements HostInstance {
|
|
|
184
317
|
this.frameMaxWidth = typeof props.maxWidth === "number" ? props.maxWidth : undefined
|
|
185
318
|
this.frameMinHeight = typeof props.minHeight === "number" ? props.minHeight : undefined
|
|
186
319
|
this.frameMaxHeight = typeof props.maxHeight === "number" ? props.maxHeight : undefined
|
|
320
|
+
|
|
321
|
+
// onLayout callback
|
|
322
|
+
this.onLayout = typeof props.onLayout === "function" ? (props.onLayout as typeof this.onLayout) : undefined
|
|
187
323
|
}
|
|
188
324
|
|
|
189
325
|
destroy(): void {
|
|
326
|
+
this.clearSpringSubscriptions()
|
|
190
327
|
// Override in subclasses if cleanup needed
|
|
191
328
|
}
|
|
192
329
|
|
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)
|
package/src/index.ts
CHANGED
|
@@ -48,13 +48,17 @@ export {
|
|
|
48
48
|
} from "./highlight.js"
|
|
49
49
|
export type {
|
|
50
50
|
ScrollState,
|
|
51
|
+
TimerStatus,
|
|
52
|
+
TimerType,
|
|
51
53
|
UseKeyboardOptions,
|
|
52
54
|
UseMouseOptions,
|
|
53
55
|
UseScrollOptions,
|
|
54
56
|
UseScrollReturn,
|
|
57
|
+
UseTimerConfig,
|
|
58
|
+
UseTimerReturn,
|
|
55
59
|
} from "./hooks/index.js"
|
|
56
60
|
// Hooks
|
|
57
|
-
export { useKeyboard, useMouse, usePaste, useQuit, useScroll } from "./hooks/index.js"
|
|
61
|
+
export { useKeyboard, useMouse, usePaste, useQuit, useScroll, useTimer } from "./hooks/index.js"
|
|
58
62
|
export { useFrameStats } from "./hooks/useFrameStats.js"
|
|
59
63
|
export type { BorderKind, BoxProps } from "./hosts/box.js"
|
|
60
64
|
export type { CanvasProps, DrawContext } from "./hosts/canvas.js"
|