@effect-tui/react 0.9.1 → 0.9.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-tui/react",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
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.2",
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
  )
@@ -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,8 @@ 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
+ // Sync ref immediately so scrollToVisible uses correct size
293
+ viewportSizeRef.current = newSize
290
294
  setViewportSize(newSize)
291
295
  setViewportMeasured(true)
292
296
  },
@@ -349,10 +353,11 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
349
353
  useKeyboard(handleKey)
350
354
 
351
355
  // 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
356
+ // Uses refs to avoid stale closures - only triggers when selection changes
353
357
  const scrollToVisible = useCallback(
354
358
  (position: number, itemSize = 1, padding = 0, totalSize?: number) => {
355
359
  const currentOffset = offsetRef.current
360
+ const currentViewportSize = viewportSizeRef.current
356
361
  const itemStart = position * itemSize
357
362
  const itemEnd = itemStart + itemSize
358
363
  // Use provided totalSize if available (more accurate than potentially stale contentSize)
@@ -363,12 +368,12 @@ export function useScroll(options: UseScrollOptions = {}): UseScrollReturn {
363
368
  setOffset(Math.max(0, itemStart - padding))
364
369
  }
365
370
  // 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))
371
+ else if (itemEnd > currentOffset + currentViewportSize - padding) {
372
+ const currentMaxOffset = Math.max(0, effectiveContentSize - currentViewportSize)
373
+ setOffset(Math.min(currentMaxOffset, itemEnd - currentViewportSize + padding))
369
374
  }
370
375
  },
371
- [viewportSize, contentSize, setOffset],
376
+ [contentSize, setOffset],
372
377
  )
373
378
 
374
379
  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
 
@@ -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
+ }