@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.
Files changed (85) hide show
  1. package/dist/src/components/ListView.d.ts.map +1 -1
  2. package/dist/src/components/ListView.js +7 -2
  3. package/dist/src/components/ListView.js.map +1 -1
  4. package/dist/src/dev/Toast.d.ts.map +1 -1
  5. package/dist/src/dev/Toast.js +24 -15
  6. package/dist/src/dev/Toast.js.map +1 -1
  7. package/dist/src/dev.d.ts +1 -1
  8. package/dist/src/dev.d.ts.map +1 -1
  9. package/dist/src/dev.js.map +1 -1
  10. package/dist/src/hooks/index.d.ts +2 -0
  11. package/dist/src/hooks/index.d.ts.map +1 -1
  12. package/dist/src/hooks/index.js +1 -0
  13. package/dist/src/hooks/index.js.map +1 -1
  14. package/dist/src/hooks/use-scroll.js +12 -12
  15. package/dist/src/hooks/use-scroll.js.map +1 -1
  16. package/dist/src/hooks/use-timer.d.ts +57 -0
  17. package/dist/src/hooks/use-timer.d.ts.map +1 -0
  18. package/dist/src/hooks/use-timer.js +94 -0
  19. package/dist/src/hooks/use-timer.js.map +1 -0
  20. package/dist/src/hosts/base.d.ts +28 -0
  21. package/dist/src/hosts/base.d.ts.map +1 -1
  22. package/dist/src/hosts/base.js +118 -0
  23. package/dist/src/hosts/base.js.map +1 -1
  24. package/dist/src/hosts/box.d.ts +6 -3
  25. package/dist/src/hosts/box.d.ts.map +1 -1
  26. package/dist/src/hosts/box.js +10 -3
  27. package/dist/src/hosts/box.js.map +1 -1
  28. package/dist/src/hosts/scroll.d.ts +0 -2
  29. package/dist/src/hosts/scroll.d.ts.map +1 -1
  30. package/dist/src/hosts/scroll.js +3 -7
  31. package/dist/src/hosts/scroll.js.map +1 -1
  32. package/dist/src/hosts/text.d.ts +5 -2
  33. package/dist/src/hosts/text.d.ts.map +1 -1
  34. package/dist/src/hosts/text.js +7 -3
  35. package/dist/src/hosts/text.js.map +1 -1
  36. package/dist/src/index.d.ts +2 -2
  37. package/dist/src/index.d.ts.map +1 -1
  38. package/dist/src/index.js +1 -1
  39. package/dist/src/index.js.map +1 -1
  40. package/dist/src/motion/brands.d.ts +9 -0
  41. package/dist/src/motion/brands.d.ts.map +1 -0
  42. package/dist/src/motion/brands.js +9 -0
  43. package/dist/src/motion/brands.js.map +1 -0
  44. package/dist/src/motion/color-motion-value.d.ts +4 -0
  45. package/dist/src/motion/color-motion-value.d.ts.map +1 -1
  46. package/dist/src/motion/color-motion-value.js +4 -0
  47. package/dist/src/motion/color-motion-value.js.map +1 -1
  48. package/dist/src/motion/hooks.d.ts +29 -3
  49. package/dist/src/motion/hooks.d.ts.map +1 -1
  50. package/dist/src/motion/hooks.js +40 -4
  51. package/dist/src/motion/hooks.js.map +1 -1
  52. package/dist/src/motion/index.d.ts +1 -1
  53. package/dist/src/motion/index.d.ts.map +1 -1
  54. package/dist/src/motion/index.js +1 -1
  55. package/dist/src/motion/index.js.map +1 -1
  56. package/dist/src/motion/motion-value.d.ts +6 -2
  57. package/dist/src/motion/motion-value.d.ts.map +1 -1
  58. package/dist/src/motion/motion-value.js +6 -2
  59. package/dist/src/motion/motion-value.js.map +1 -1
  60. package/dist/src/motion/use-sequence.d.ts +10 -2
  61. package/dist/src/motion/use-sequence.d.ts.map +1 -1
  62. package/dist/src/motion/use-sequence.js +101 -11
  63. package/dist/src/motion/use-sequence.js.map +1 -1
  64. package/dist/src/reconciler/types.d.ts +24 -1
  65. package/dist/src/reconciler/types.d.ts.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +2 -2
  68. package/src/components/ListView.tsx +8 -2
  69. package/src/dev/Toast.tsx +27 -17
  70. package/src/dev.tsx +4 -4
  71. package/src/hooks/index.ts +2 -0
  72. package/src/hooks/use-scroll.ts +12 -12
  73. package/src/hooks/use-timer.ts +155 -0
  74. package/src/hosts/base.ts +137 -0
  75. package/src/hosts/box.ts +17 -6
  76. package/src/hosts/scroll.ts +3 -7
  77. package/src/hosts/text.ts +13 -5
  78. package/src/index.ts +5 -1
  79. package/src/motion/brands.ts +10 -0
  80. package/src/motion/color-motion-value.ts +6 -0
  81. package/src/motion/hooks.ts +50 -4
  82. package/src/motion/index.ts +3 -0
  83. package/src/motion/motion-value.ts +8 -1
  84. package/src/motion/use-sequence.ts +113 -13
  85. 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.9.3",
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.9.3",
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
- if (selectedIndex !== prevSelectedRef.current) {
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
- // Bright flash color for initial state
88
- const FLASH_COLOR = { r: 255, g: 255, b: 220, a: 1 }
89
- // Final green success color
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
- function ScreenshotToast({ message }: { message: string }) {
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 green
101
- const [bg, setBg] = useColorSpring(FLASH_COLOR, { visualDuration: 1.2, bounce: 0 })
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
- // Trigger fade after flash phase
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(SUCCESS_COLOR)
107
- }, 200) // Start fade at 200ms
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={Colors.rgb(bgValue.r, bgValue.g, bgValue.b)} padding={{ x: 1 }}>
118
- <text fg={isFlash ? Colors.rgb(40, 40, 20) : Colors.rgb(140, 230, 140)}>
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<A>(key: string): (self: A) => A {
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<A>(fallbackKey)(self)
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<A>(key)(self)
192
+ return hmr(key)(self)
193
193
  }
194
194
 
195
195
  export interface DevRenderOptions extends RendererOptions {
@@ -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"
@@ -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 - only triggers when selection changes
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
- setOffset(Math.max(0, itemStart - padding))
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
- setOffset(Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding))
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, setOffset],
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?: Color
22
- bg?: Color
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?: Color
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
- this.borderColor = props.borderColor as Color | undefined
141
- this.bg = props.bg as Color | undefined
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 as Color | undefined
152
+ this.titleColor = this.resolveSpringProp("titleColor", props.titleColor, (v) => {
153
+ this.titleColor = v as Color
154
+ }) as Color | undefined
144
155
  }
145
156
  }
@@ -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
- if (rect.w !== this.lastViewportW || rect.h !== this.lastViewportH) {
117
- this.lastViewportW = rect.w
118
- this.lastViewportH = rect.h
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?: Color
8
- bg?: Color
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
- // Always assign; props may be undefined when attribute is removed
358
- this.fg = props.fg as Color | undefined
359
- this.bg = props.bg as Color | undefined
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"