@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.
- package/dist/src/dev/Toast.d.ts.map +1 -1
- package/dist/src/dev/Toast.js +29 -35
- package/dist/src/dev/Toast.js.map +1 -1
- package/dist/src/dev.d.ts +19 -0
- package/dist/src/dev.d.ts.map +1 -1
- package/dist/src/dev.js +6 -3
- package/dist/src/dev.js.map +1 -1
- package/dist/src/hooks/use-scroll.d.ts.map +1 -1
- package/dist/src/hooks/use-scroll.js +22 -7
- package/dist/src/hooks/use-scroll.js.map +1 -1
- package/dist/src/hosts/box.d.ts.map +1 -1
- package/dist/src/hosts/box.js +6 -4
- package/dist/src/hosts/box.js.map +1 -1
- package/dist/src/hosts/scroll.d.ts.map +1 -1
- package/dist/src/hosts/scroll.js +1 -0
- package/dist/src/hosts/scroll.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- 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/index.d.ts +1 -0
- package/dist/src/motion/index.d.ts.map +1 -1
- package/dist/src/motion/index.js +1 -0
- package/dist/src/motion/index.js.map +1 -1
- package/dist/src/motion/use-sequence.d.ts +27 -0
- package/dist/src/motion/use-sequence.d.ts.map +1 -0
- package/dist/src/motion/use-sequence.js +35 -0
- package/dist/src/motion/use-sequence.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/dev/Toast.tsx +29 -43
- package/src/dev.tsx +15 -10
- package/src/hooks/use-scroll.ts +22 -7
- package/src/hosts/box.ts +6 -4
- package/src/hosts/scroll.ts +1 -0
- package/src/index.ts +2 -0
- package/src/motion/index.ts +1 -0
- 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.
|
|
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.
|
|
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
|
-
//
|
|
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
91
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
110
|
-
const [
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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={
|
|
134
|
-
<text fg={
|
|
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}
|
|
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
|
|
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!", "
|
|
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
|
-
*
|
|
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
|
package/src/hooks/use-scroll.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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 (
|
|
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 (
|
|
367
|
-
const currentMaxOffset = Math.max(0, effectiveContentSize -
|
|
368
|
-
setOffset(Math.min(currentMaxOffset, itemEnd -
|
|
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
|
-
[
|
|
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 >=
|
|
115
|
+
if (this.title && w >= 7) {
|
|
116
116
|
const titleFg = toColorValue(this.titleColor) ?? borderFg
|
|
117
117
|
const titleStyle = palette.id({ fg: titleFg })
|
|
118
|
-
|
|
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
|
-
|
|
124
|
+
const titleText = ` ${displayTitle} `
|
|
125
|
+
buffer.drawText(titleX, y, titleText, titleStyle, titleText.length)
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
|
package/src/hosts/scroll.ts
CHANGED
|
@@ -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
package/src/motion/index.ts
CHANGED
|
@@ -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
|
+
}
|