@effect-tui/react 0.9.0 → 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.0",
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.0",
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
@@ -2,18 +2,22 @@
2
2
  // Beautiful, minimal notifications that appear at the top of the screen
3
3
 
4
4
  import { Colors } from "@effect-tui/core"
5
- import { createContext, type ReactNode, useCallback, useContext, useState } from "react"
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
9
11
  // ─────────────────────────────────────────────────────────────
10
12
 
11
- export type ToastType = "success" | "info" | "warning" | "error"
13
+ export type ToastType = "success" | "info" | "warning" | "error" | "screenshot"
12
14
 
13
15
  export interface Toast {
14
16
  id: number
15
17
  message: string
16
18
  type: ToastType
19
+ /** Timestamp when the toast was created (for animations) */
20
+ createdAt: number
17
21
  }
18
22
 
19
23
  export interface ToastContextValue {
@@ -48,6 +52,7 @@ const TOAST_STYLES: Record<
48
52
  info: { bg: Colors.rgb(30, 50, 80), fg: Colors.rgb(140, 180, 230), icon: "ℹ" },
49
53
  warning: { bg: Colors.rgb(80, 60, 20), fg: Colors.rgb(230, 200, 100), icon: "⚠" },
50
54
  error: { bg: Colors.rgb(80, 30, 30), fg: Colors.rgb(230, 140, 140), icon: "✗" },
55
+ screenshot: { bg: Colors.rgb(30, 70, 40), fg: Colors.rgb(140, 230, 140), icon: "📷" },
51
56
  }
52
57
 
53
58
  // ─────────────────────────────────────────────────────────────
@@ -61,7 +66,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
61
66
 
62
67
  const show = useCallback((message: string, type: ToastType = "info", durationMs = 2000) => {
63
68
  const id = ++toastId
64
- setToasts((prev) => [...prev, { id, message, type }])
69
+ setToasts((prev) => [...prev, { id, message, type, createdAt: Date.now() }])
65
70
 
66
71
  setTimeout(() => {
67
72
  setToasts((prev) => prev.filter((t) => t.id !== id))
@@ -75,6 +80,49 @@ export function ToastProvider({ children }: { children: ReactNode }) {
75
80
  return <ToastContext.Provider value={{ toasts, show, dismiss }}>{children}</ToastContext.Provider>
76
81
  }
77
82
 
83
+ // ─────────────────────────────────────────────────────────────
84
+ // Screenshot Toast Animation
85
+ // ─────────────────────────────────────────────────────────────
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 }
91
+
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
+ })
99
+
100
+ // Background: bright flash → fade to green
101
+ const [bg, setBg] = useColorSpring(FLASH_COLOR, { visualDuration: 1.2, bounce: 0 })
102
+
103
+ // Trigger fade after flash phase
104
+ useEffect(() => {
105
+ const timer = setTimeout(() => {
106
+ setBg(SUCCESS_COLOR)
107
+ }, 200) // Start fade at 200ms
108
+ return () => clearTimeout(timer)
109
+ }, [setBg])
110
+
111
+ const bgValue = bg.get()
112
+ const isFlash = emoji === "📸"
113
+
114
+ return (
115
+ <hstack>
116
+ <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)}>
119
+ {` ${emoji} ${message} `}
120
+ </text>
121
+ </box>
122
+ </hstack>
123
+ )
124
+ }
125
+
78
126
  // ─────────────────────────────────────────────────────────────
79
127
  // Toast Display Component
80
128
  // ─────────────────────────────────────────────────────────────
@@ -86,6 +134,12 @@ export function ToastContainer() {
86
134
 
87
135
  // Show only the most recent toast
88
136
  const toast = toasts[toasts.length - 1]
137
+
138
+ // Special animated toast for screenshots
139
+ if (toast.type === "screenshot") {
140
+ return <ScreenshotToast message={toast.message} />
141
+ }
142
+
89
143
  const style = TOAST_STYLES[toast.type]
90
144
 
91
145
  // Compact pill in upper right
@@ -111,7 +165,7 @@ export function useScreenshotToast() {
111
165
 
112
166
  return useCallback(
113
167
  (path: string) => {
114
- show(`Screenshot saved: ${path}`, "success", 2500)
168
+ show(`Screenshot copied!`, "screenshot", 2500)
115
169
  },
116
170
  [show],
117
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
+ }