@effect-tui/react 0.9.1 → 0.9.3

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 (38) hide show
  1. package/dist/src/dev/Toast.d.ts.map +1 -1
  2. package/dist/src/dev/Toast.js +29 -35
  3. package/dist/src/dev/Toast.js.map +1 -1
  4. package/dist/src/dev.d.ts +19 -0
  5. package/dist/src/dev.d.ts.map +1 -1
  6. package/dist/src/dev.js +6 -3
  7. package/dist/src/dev.js.map +1 -1
  8. package/dist/src/hooks/use-scroll.d.ts.map +1 -1
  9. package/dist/src/hooks/use-scroll.js +22 -7
  10. package/dist/src/hooks/use-scroll.js.map +1 -1
  11. package/dist/src/hosts/box.d.ts.map +1 -1
  12. package/dist/src/hosts/box.js +6 -4
  13. package/dist/src/hosts/box.js.map +1 -1
  14. package/dist/src/hosts/scroll.d.ts.map +1 -1
  15. package/dist/src/hosts/scroll.js +1 -0
  16. package/dist/src/hosts/scroll.js.map +1 -1
  17. package/dist/src/index.d.ts +1 -1
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +1 -1
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/motion/index.d.ts +1 -0
  22. package/dist/src/motion/index.d.ts.map +1 -1
  23. package/dist/src/motion/index.js +1 -0
  24. package/dist/src/motion/index.js.map +1 -1
  25. package/dist/src/motion/use-sequence.d.ts +27 -0
  26. package/dist/src/motion/use-sequence.d.ts.map +1 -0
  27. package/dist/src/motion/use-sequence.js +35 -0
  28. package/dist/src/motion/use-sequence.js.map +1 -0
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +2 -2
  31. package/src/dev/Toast.tsx +29 -43
  32. package/src/dev.tsx +15 -10
  33. package/src/hooks/use-scroll.ts +22 -7
  34. package/src/hosts/box.ts +6 -4
  35. package/src/hosts/scroll.ts +1 -0
  36. package/src/index.ts +2 -0
  37. package/src/motion/index.ts +1 -0
  38. package/src/motion/use-sequence.ts +51 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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.1",
86
+ "@effect-tui/core": "^0.9.3",
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
@@ -3,6 +3,8 @@
3
3
 
4
4
  import { Colors } from "@effect-tui/core"
5
5
  import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"
6
+ import { useColorSpring } from "../motion/hooks.js"
7
+ import { useSequence } from "../motion/use-sequence.js"
6
8
 
7
9
  // ─────────────────────────────────────────────────────────────
8
10
  // Types
@@ -82,56 +84,40 @@ export function ToastProvider({ children }: { children: ReactNode }) {
82
84
  // Screenshot Toast Animation
83
85
  // ─────────────────────────────────────────────────────────────
84
86
 
85
- // Animation phases: camera flash → success
86
- type ScreenshotPhase = "camera" | "flash" | "success"
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
91
 
88
- const SCREENSHOT_PHASES: Record<
89
- ScreenshotPhase,
90
- { icon: string; bg: ReturnType<typeof Colors.rgb>; fg: ReturnType<typeof Colors.rgb> }
91
- > = {
92
- camera: {
93
- icon: "📷",
94
- bg: Colors.rgb(40, 50, 60),
95
- fg: Colors.rgb(180, 200, 220),
96
- },
97
- flash: {
98
- icon: "⚡",
99
- bg: Colors.rgb(255, 255, 200), // Bright flash!
100
- fg: Colors.rgb(60, 60, 40),
101
- },
102
- success: {
103
- icon: "✓",
104
- bg: Colors.rgb(30, 70, 40),
105
- fg: Colors.rgb(140, 230, 140),
106
- },
107
- }
92
+ function ScreenshotToast({ message }: { message: string }) {
93
+ // Emoji sequence: 📷 → 📸 → 📷 (camera → flash → camera)
94
+ const emoji = useSequence({
95
+ keyframes: ["📷", "📸", "📷"],
96
+ times: [0, 0.08, 0.2], // Flash at 8%, back at 20%
97
+ duration: 2500,
98
+ })
108
99
 
109
- function ScreenshotToast({ message, createdAt }: { message: string; createdAt: number }) {
110
- const [phase, setPhase] = useState<ScreenshotPhase>("camera")
100
+ // Background: bright flash fade to green
101
+ const [bg, setBg] = useColorSpring(FLASH_COLOR, { visualDuration: 1.2, bounce: 0 })
111
102
 
103
+ // Trigger fade after flash phase
112
104
  useEffect(() => {
113
- // Phase timing: camera (0-120ms) → flash (120-280ms) success (280ms+)
114
- const elapsed = Date.now() - createdAt
115
- const cameraDelay = Math.max(0, 120 - elapsed)
116
- const flashDelay = Math.max(0, 280 - elapsed)
117
-
118
- const flashTimer = setTimeout(() => setPhase("flash"), cameraDelay)
119
- const successTimer = setTimeout(() => setPhase("success"), flashDelay)
105
+ const timer = setTimeout(() => {
106
+ setBg(SUCCESS_COLOR)
107
+ }, 200) // Start fade at 200ms
108
+ return () => clearTimeout(timer)
109
+ }, [setBg])
120
110
 
121
- return () => {
122
- clearTimeout(flashTimer)
123
- clearTimeout(successTimer)
124
- }
125
- }, [createdAt])
126
-
127
- const style = SCREENSHOT_PHASES[phase]
128
- const content = ` ${style.icon} ${message} `
111
+ const bgValue = bg.get()
112
+ const isFlash = emoji === "📸"
129
113
 
130
114
  return (
131
115
  <hstack>
132
116
  <spacer />
133
- <box bg={style.bg} padding={{ x: 1 }}>
134
- <text fg={style.fg}>{content}</text>
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)}>
119
+ {` ${emoji} ${message} `}
120
+ </text>
135
121
  </box>
136
122
  </hstack>
137
123
  )
@@ -151,7 +137,7 @@ export function ToastContainer() {
151
137
 
152
138
  // Special animated toast for screenshots
153
139
  if (toast.type === "screenshot") {
154
- return <ScreenshotToast message={toast.message} createdAt={toast.createdAt} />
140
+ return <ScreenshotToast message={toast.message} />
155
141
  }
156
142
 
157
143
  const style = TOAST_STYLES[toast.type]
@@ -179,7 +165,7 @@ export function useScreenshotToast() {
179
165
 
180
166
  return useCallback(
181
167
  (path: string) => {
182
- show(`Screenshot saved: ${path}`, "success", 2500)
168
+ show(`Screenshot copied!`, "screenshot", 2500)
183
169
  },
184
170
  [show],
185
171
  )
package/src/dev.tsx CHANGED
@@ -309,7 +309,7 @@ function ScreenshotHandler() {
309
309
  const tmpPath = `/tmp/tui-screenshot-${Date.now()}.txt`
310
310
  Bun.write(tmpPath, ansiOutput)
311
311
 
312
- show("Screenshot copied!", "success", 1500)
312
+ show("Screenshot copied!", "screenshot", 2500)
313
313
  key.preventDefault?.()
314
314
  }
315
315
  })
@@ -336,26 +336,31 @@ function StatsOverlay({ sampleMs, title }: { sampleMs?: number; title?: string }
336
336
  )
337
337
  }
338
338
 
339
+ export interface DevWrapperProps {
340
+ children?: React.ReactNode
341
+ showStats?: boolean
342
+ statsSampleMs?: number
343
+ statsTitle?: string
344
+ mode?: "fullscreen" | "inline"
345
+ }
346
+
339
347
  /**
340
- * Internal wrapper component that provides dev mode features:
348
+ * Wrapper component that provides dev mode features:
341
349
  * - Debug console panel (` backtick to toggle)
342
350
  * - Screenshot support (~ tilde) with toast notification
343
351
  * - Auto-show console on errors
344
352
  * - Optional renderer stats overlay (showStats)
353
+ *
354
+ * Use this to wrap your app when you need dev features but can't use devRender
355
+ * (e.g., when you need to pass props to your App component).
345
356
  */
346
- function DevWrapper({
357
+ export function DevWrapper({
347
358
  children,
348
359
  showStats,
349
360
  statsSampleMs,
350
361
  statsTitle,
351
362
  mode,
352
- }: {
353
- children?: React.ReactNode
354
- showStats?: boolean
355
- statsSampleMs?: number
356
- statsTitle?: string
357
- mode?: "fullscreen" | "inline"
358
- }) {
363
+ }: DevWrapperProps) {
359
364
  const { visible } = useConsole({ autoShowOnError: true, initiallyVisible: false })
360
365
 
361
366
  // Inline mode: content flows naturally in vstack
@@ -209,9 +209,11 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
209
209
  // Fractional accumulator for smooth sub-pixel scrolling
210
210
  const accumulatorRef = useRef(0)
211
211
 
212
- // Ref for scrollToVisible so it doesn't change on every scroll
212
+ // Refs for scrollToVisible so it doesn't change on every scroll
213
213
  const offsetRef = useRef(offset)
214
214
  offsetRef.current = offset
215
+ // viewportSizeRef is ONLY updated by handleViewportSize to avoid stale state overwriting
216
+ const viewportSizeRef = useRef(viewportSize)
215
217
 
216
218
  // Calculate derived state
217
219
  const maxOffset = Math.max(0, contentSize - viewportSize)
@@ -287,6 +289,9 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
287
289
  const handleViewportSize = useCallback(
288
290
  (width: number, height: number) => {
289
291
  const newSize = axis === "vertical" ? height : width
292
+ console.log("[useScroll] viewport size reported", { width, height, axis, newSize })
293
+ // Sync ref immediately so scrollToVisible uses correct size
294
+ viewportSizeRef.current = newSize
290
295
  setViewportSize(newSize)
291
296
  setViewportMeasured(true)
292
297
  },
@@ -349,26 +354,36 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
349
354
  useKeyboard(handleKey)
350
355
 
351
356
  // Scroll to make a position visible (for keeping selection in view)
352
- // Uses refs to avoid changing on every scroll - only triggers when selection changes
357
+ // Uses refs to avoid stale closures - only triggers when selection changes
353
358
  const scrollToVisible = useCallback(
354
359
  (position: number, itemSize = 1, padding = 0, totalSize?: number) => {
355
360
  const currentOffset = offsetRef.current
361
+ const currentViewportSize = viewportSizeRef.current
356
362
  const itemStart = position * itemSize
357
363
  const itemEnd = itemStart + itemSize
358
364
  // Use provided totalSize if available (more accurate than potentially stale contentSize)
359
365
  const effectiveContentSize = totalSize ?? contentSize
366
+ const isAbove = itemStart < currentOffset + padding
367
+ const isBelow = itemEnd > currentOffset + currentViewportSize - padding
368
+ console.log("[useScroll] scrollToVisible", {
369
+ currentViewportSize,
370
+ itemEnd,
371
+ currentOffset,
372
+ isAbove,
373
+ isBelow,
374
+ })
360
375
 
361
376
  // If item is above viewport, scroll up to show it
362
- if (itemStart < currentOffset + padding) {
377
+ if (isAbove) {
363
378
  setOffset(Math.max(0, itemStart - padding))
364
379
  }
365
380
  // If item is below viewport, scroll down to show it
366
- else if (itemEnd > currentOffset + viewportSize - padding) {
367
- const currentMaxOffset = Math.max(0, effectiveContentSize - viewportSize)
368
- setOffset(Math.min(currentMaxOffset, itemEnd - viewportSize + padding))
381
+ else if (isBelow) {
382
+ const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
383
+ setOffset(Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding))
369
384
  }
370
385
  },
371
- [viewportSize, contentSize, setOffset],
386
+ [contentSize, setOffset],
372
387
  )
373
388
 
374
389
  const state: ScrollState = {
package/src/hosts/box.ts CHANGED
@@ -112,15 +112,17 @@ export class BoxHost extends SingleChildHost {
112
112
  drawBorder(buffer, x, y, w, h, chars, borderStyle)
113
113
 
114
114
  // Draw title on top border if present
115
- if (this.title && w >= 5) {
115
+ if (this.title && w >= 7) {
116
116
  const titleFg = toColorValue(this.titleColor) ?? borderFg
117
117
  const titleStyle = palette.id({ fg: titleFg })
118
- const maxTitleLen = w - 4 // Leave room for "─ " and " ─"
118
+ // Reserve: ┌─ (2) + space before (1) + space after (1) + ─┐ (2) = 6 chars
119
+ const maxTitleLen = w - 6
119
120
  const displayTitle =
120
121
  this.title.length > maxTitleLen ? this.title.slice(0, maxTitleLen - 1) + "…" : this.title
121
- // Draw " Title " starting at x+2
122
+ // Draw " Title " starting at x+2, limiting width to just the title text
122
123
  const titleX = x + 2
123
- buffer.drawText(titleX, y, ` ${displayTitle} `, titleStyle)
124
+ const titleText = ` ${displayTitle} `
125
+ buffer.drawText(titleX, y, titleText, titleStyle, titleText.length)
124
126
  }
125
127
  }
126
128
 
@@ -103,6 +103,7 @@ export class ScrollHost extends SingleChildHost {
103
103
 
104
104
  override layout(rect: Rect): void {
105
105
  super.layout(rect)
106
+ console.log("[ScrollHost] layout rect", { x: rect.x, y: rect.y, w: rect.w, h: rect.h })
106
107
 
107
108
  // Report content size if changed (deferred from measure() to keep it pure)
108
109
  if (this.contentWidth !== this.lastReportedContentW || this.contentHeight !== this.lastReportedContentH) {
package/src/index.ts CHANGED
@@ -33,6 +33,8 @@ export {
33
33
  type DevRenderResult,
34
34
  devMain,
35
35
  devRender,
36
+ DevWrapper,
37
+ type DevWrapperProps,
36
38
  hmr,
37
39
  hmrState,
38
40
  } from "./dev.js"
@@ -15,3 +15,4 @@ export {
15
15
  useSpringRenderer,
16
16
  useSprings,
17
17
  } from "./motion-value.js"
18
+ export { useSequence, type SequenceOptions } from "./use-sequence.js"
@@ -0,0 +1,51 @@
1
+ // useSequence - Timed discrete state sequences (motion.dev-style keyframes)
2
+ // For animating through discrete values like emojis, strings, or any non-numeric state
3
+
4
+ import { useEffect, useState } from "react"
5
+
6
+ export interface SequenceOptions<T> {
7
+ /** Array of values to animate through */
8
+ keyframes: T[]
9
+ /** Position of each keyframe as 0-1 fraction (must match keyframes length) */
10
+ times: number[]
11
+ /** Total duration in milliseconds */
12
+ duration: number
13
+ /** Start automatically (default: true) */
14
+ autoPlay?: boolean
15
+ }
16
+
17
+ /**
18
+ * Animate through a sequence of discrete values with precise timing.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * // Emoji sequence: camera → flash → camera
23
+ * const emoji = useSequence({
24
+ * keyframes: ["📷", "📸", "📷"],
25
+ * times: [0, 0.08, 0.2], // Flash at 8%, back at 20%
26
+ * duration: 2500,
27
+ * })
28
+ *
29
+ * return <text>{emoji}</text>
30
+ * ```
31
+ */
32
+ export function useSequence<T>(options: SequenceOptions<T>): T {
33
+ const { keyframes, times, duration, autoPlay = true } = options
34
+ const [index, setIndex] = useState(0)
35
+
36
+ useEffect(() => {
37
+ if (!autoPlay || keyframes.length <= 1) return
38
+
39
+ const timers: NodeJS.Timeout[] = []
40
+
41
+ // Schedule each keyframe transition
42
+ for (let i = 1; i < keyframes.length; i++) {
43
+ const delayMs = times[i]! * duration
44
+ timers.push(setTimeout(() => setIndex(i), delayMs))
45
+ }
46
+
47
+ return () => timers.forEach(clearTimeout)
48
+ }, [keyframes, times, duration, autoPlay])
49
+
50
+ return keyframes[index]!
51
+ }