@dfosco/storyboard-react 4.2.3 → 4.2.5
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 +3 -3
- package/src/CommandPalette/CommandPalette.jsx +29 -8
- package/src/Viewfinder.jsx +7 -4
- package/src/canvas/CanvasPage.jsx +60 -3
- package/src/canvas/WebGLContextPool.jsx +292 -0
- package/src/canvas/WebGLContextPool.test.jsx +165 -0
- package/src/canvas/componentIsolate.jsx +45 -15
- package/src/canvas/componentSetIsolate.jsx +257 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +2 -208
- package/src/canvas/widgets/ComponentWidget.jsx +0 -139
- package/src/canvas/widgets/ComponentWidget.module.css +0 -26
- package/src/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
- package/src/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
- package/src/canvas/widgets/PromptWidget.jsx +16 -2
- package/src/canvas/widgets/StorySetWidget.jsx +208 -0
- package/src/canvas/widgets/StorySetWidget.module.css +89 -0
- package/src/canvas/widgets/StoryWidget.jsx +3 -4
- package/src/canvas/widgets/TerminalWidget.jsx +146 -100
- package/src/canvas/widgets/TerminalWidget.module.css +23 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +1 -61
- package/src/canvas/widgets/expandUtils.js +3 -4
- package/src/canvas/widgets/index.js +2 -2
- package/src/canvas/widgets/snapshotDisplay.test.jsx +1 -1
- package/src/context.jsx +70 -7
- package/src/vite/data-plugin.js +8 -2
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.2.
|
|
3
|
+
"version": "4.2.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@base-ui/react": "^1.4.0",
|
|
7
|
-
"@dfosco/storyboard-core": "4.2.
|
|
8
|
-
"@dfosco/tiny-canvas": "4.2.
|
|
7
|
+
"@dfosco/storyboard-core": "4.2.5",
|
|
8
|
+
"@dfosco/tiny-canvas": "4.2.5",
|
|
9
9
|
"@neodrag/react": "^2.3.1",
|
|
10
10
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
11
11
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
|
@@ -254,19 +254,17 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
|
|
|
254
254
|
onClick: () => { window.location.href = resolvedUrl },
|
|
255
255
|
})
|
|
256
256
|
} else {
|
|
257
|
-
// Menu tools: close palette and
|
|
257
|
+
// Menu tools: close palette and dispatch event to open the toolbar menu
|
|
258
258
|
if (tool.render === 'menu') {
|
|
259
|
-
const
|
|
259
|
+
const handlerId = tool.handler || `core:${toolId}`
|
|
260
260
|
remainingItems.push({
|
|
261
261
|
id: `cfg:${section.id}:${toolId}`,
|
|
262
262
|
children: label,
|
|
263
263
|
keywords: [label, toolId].filter(Boolean),
|
|
264
264
|
showType: false,
|
|
265
265
|
onClick: () => {
|
|
266
|
-
// Find and click the toolbar button
|
|
267
266
|
setTimeout(() => {
|
|
268
|
-
|
|
269
|
-
if (btn) btn.click()
|
|
267
|
+
window.dispatchEvent(new CustomEvent('storyboard:open-tool-menu', { detail: { action: handlerId } }))
|
|
270
268
|
}, 100)
|
|
271
269
|
},
|
|
272
270
|
})
|
|
@@ -545,9 +543,28 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
|
|
|
545
543
|
if (f.canvases) for (const c of f.canvases) sourceItems.push({ name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'canvas' })
|
|
546
544
|
}
|
|
547
545
|
} else if (section.source === 'prototypes') {
|
|
548
|
-
|
|
546
|
+
const formatFlowName = (name) => name.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
|
547
|
+
const pushProtoFlows = (p) => {
|
|
548
|
+
if (p.isExternal) {
|
|
549
|
+
sourceItems.push({ name: p.name, route: p.externalUrl, id: p.dirName, type: 'prototype', isExternal: true })
|
|
550
|
+
} else if (p.flows.length <= 1) {
|
|
551
|
+
const route = p.flows.length === 1 ? `${prefix}${p.flows[0].route}` : `${prefix}/${p.dirName}`
|
|
552
|
+
sourceItems.push({ name: p.name, route, id: p.dirName, type: 'prototype' })
|
|
553
|
+
} else {
|
|
554
|
+
for (const flow of p.flows) {
|
|
555
|
+
const flowLabel = flow.meta?.title || formatFlowName(flow.name)
|
|
556
|
+
sourceItems.push({
|
|
557
|
+
name: `${p.name} – ${flowLabel}`,
|
|
558
|
+
route: `${prefix}${flow.route}`,
|
|
559
|
+
id: `${p.dirName}/${flow.name}`,
|
|
560
|
+
type: 'prototype',
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
for (const p of index.prototypes) pushProtoFlows(p)
|
|
549
566
|
for (const f of index.folders) {
|
|
550
|
-
for (const p of f.prototypes)
|
|
567
|
+
for (const p of f.prototypes) pushProtoFlows(p)
|
|
551
568
|
}
|
|
552
569
|
} else if (section.source === 'stories') {
|
|
553
570
|
for (const name of listStories()) {
|
|
@@ -588,7 +605,11 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
|
|
|
588
605
|
url: item.route,
|
|
589
606
|
onClick: () => {
|
|
590
607
|
trackRecent(item.type, item.id, item.name)
|
|
591
|
-
|
|
608
|
+
if (item.isExternal) {
|
|
609
|
+
window.open(item.route, '_blank')
|
|
610
|
+
} else {
|
|
611
|
+
window.location.href = item.route
|
|
612
|
+
}
|
|
592
613
|
},
|
|
593
614
|
})),
|
|
594
615
|
},
|
package/src/Viewfinder.jsx
CHANGED
|
@@ -1100,10 +1100,13 @@ export default function Workspace({
|
|
|
1100
1100
|
|
|
1101
1101
|
// Prototypes (ungrouped + from folders)
|
|
1102
1102
|
const addProto = (proto) => {
|
|
1103
|
-
//
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
1103
|
+
// Prefer a flow marked as default, fall back to the first flow
|
|
1104
|
+
const defaultFlow = proto.flows?.find(f => f.meta?.default === true)
|
|
1105
|
+
const route = defaultFlow
|
|
1106
|
+
? defaultFlow.route
|
|
1107
|
+
: proto.flows?.length > 0
|
|
1108
|
+
? proto.flows[0].route
|
|
1109
|
+
: `/${proto.dirName}`
|
|
1107
1110
|
|
|
1108
1111
|
items.push({
|
|
1109
1112
|
id: `proto:${proto.dirName}`,
|
|
@@ -15,9 +15,9 @@ import { getCanvasZoom } from '@dfosco/storyboard-core'
|
|
|
15
15
|
import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
|
|
16
16
|
import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
|
|
17
17
|
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
18
|
+
import { WebGLContextPoolProvider, usePoolVisibilityUpdater } from './WebGLContextPool.jsx'
|
|
18
19
|
|
|
19
20
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
20
|
-
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
21
21
|
import useUndoRedo from './useUndoRedo.js'
|
|
22
22
|
import useMarqueeSelect from './useMarqueeSelect.js'
|
|
23
23
|
import MarqueeOverlay from './MarqueeOverlay.jsx'
|
|
@@ -1086,6 +1086,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1086
1086
|
position,
|
|
1087
1087
|
})
|
|
1088
1088
|
if (result.success && result.widget) {
|
|
1089
|
+
if (result.hotSession?.webglReady) {
|
|
1090
|
+
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
1091
|
+
}
|
|
1089
1092
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
1090
1093
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
1091
1094
|
}
|
|
@@ -1239,6 +1242,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1239
1242
|
position,
|
|
1240
1243
|
})
|
|
1241
1244
|
if (result.success && result.widget) {
|
|
1245
|
+
if (result.hotSession?.webglReady) {
|
|
1246
|
+
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
1247
|
+
}
|
|
1242
1248
|
newWidgets.push(result.widget)
|
|
1243
1249
|
}
|
|
1244
1250
|
} catch (err) {
|
|
@@ -1996,6 +2002,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1996
2002
|
position: pos,
|
|
1997
2003
|
})
|
|
1998
2004
|
if (result.success && result.widget) {
|
|
2005
|
+
// Hot pool WebGL-ready flag: add to props so TerminalWidget starts PINNED
|
|
2006
|
+
if (result.hotSession?.webglReady) {
|
|
2007
|
+
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
2008
|
+
}
|
|
1999
2009
|
undoRedo.snapshot(stateRef.current, 'add')
|
|
2000
2010
|
setLocalWidgets((prev) => [...(prev || []), result.widget])
|
|
2001
2011
|
setSelectedWidgetIds(new Set([result.widget.id]))
|
|
@@ -2190,6 +2200,50 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2190
2200
|
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
2191
2201
|
}, [localWidgets, localConnectors])
|
|
2192
2202
|
|
|
2203
|
+
// ── WebGL context pool: viewport-based visibility tracking ──
|
|
2204
|
+
const updatePoolVisibility = usePoolVisibilityUpdater()
|
|
2205
|
+
const poolRafRef = useRef(null)
|
|
2206
|
+
|
|
2207
|
+
// Compute viewport rect in canvas coordinates and update terminal priorities
|
|
2208
|
+
const syncPoolVisibility = useCallback(() => {
|
|
2209
|
+
const el = scrollRef.current
|
|
2210
|
+
if (!el || !localWidgets) return
|
|
2211
|
+
const currentZoom = zoomRef.current || 100
|
|
2212
|
+
const currentScale = currentZoom / 100
|
|
2213
|
+
const viewportRect = {
|
|
2214
|
+
x: el.scrollLeft / currentScale,
|
|
2215
|
+
y: el.scrollTop / currentScale,
|
|
2216
|
+
w: el.clientWidth / currentScale,
|
|
2217
|
+
h: el.clientHeight / currentScale,
|
|
2218
|
+
}
|
|
2219
|
+
updatePoolVisibility(viewportRect, localWidgets, selectedWidgetIds, null)
|
|
2220
|
+
}, [updatePoolVisibility, localWidgets, selectedWidgetIds])
|
|
2221
|
+
|
|
2222
|
+
// Throttle visibility updates via rAF on scroll
|
|
2223
|
+
useEffect(() => {
|
|
2224
|
+
const el = scrollRef.current
|
|
2225
|
+
if (!el) return
|
|
2226
|
+
function onScroll() {
|
|
2227
|
+
if (poolRafRef.current) return
|
|
2228
|
+
poolRafRef.current = requestAnimationFrame(() => {
|
|
2229
|
+
poolRafRef.current = null
|
|
2230
|
+
syncPoolVisibility()
|
|
2231
|
+
})
|
|
2232
|
+
}
|
|
2233
|
+
el.addEventListener('scroll', onScroll, { passive: true })
|
|
2234
|
+
// Initial sync
|
|
2235
|
+
syncPoolVisibility()
|
|
2236
|
+
return () => {
|
|
2237
|
+
el.removeEventListener('scroll', onScroll)
|
|
2238
|
+
if (poolRafRef.current) cancelAnimationFrame(poolRafRef.current)
|
|
2239
|
+
}
|
|
2240
|
+
}, [syncPoolVisibility])
|
|
2241
|
+
|
|
2242
|
+
// Re-sync on zoom changes
|
|
2243
|
+
useEffect(() => {
|
|
2244
|
+
syncPoolVisibility()
|
|
2245
|
+
}, [zoom, syncPoolVisibility])
|
|
2246
|
+
|
|
2193
2247
|
// Delete selected widget on Delete/Backspace key
|
|
2194
2248
|
useEffect(() => {
|
|
2195
2249
|
function handleSelectStart(e) {
|
|
@@ -2427,6 +2481,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2427
2481
|
position: { x: baseX + relX, y: baseY + relY },
|
|
2428
2482
|
})
|
|
2429
2483
|
if (result.success && result.widget) {
|
|
2484
|
+
if (result.hotSession?.webglReady) {
|
|
2485
|
+
result.widget.props = { ...result.widget.props, webglReady: true }
|
|
2486
|
+
}
|
|
2430
2487
|
newWidgets.push(result.widget)
|
|
2431
2488
|
}
|
|
2432
2489
|
}
|
|
@@ -2954,7 +3011,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2954
3011
|
const filteredConnectors = localConnectors
|
|
2955
3012
|
|
|
2956
3013
|
return (
|
|
2957
|
-
|
|
3014
|
+
<WebGLContextPoolProvider>
|
|
2958
3015
|
<div className={styles.canvasTitle}>
|
|
2959
3016
|
<a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
|
|
2960
3017
|
<Icon name="home" size={16} color="#fff" />
|
|
@@ -3029,6 +3086,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
3029
3086
|
</button>
|
|
3030
3087
|
</aside>
|
|
3031
3088
|
)}
|
|
3032
|
-
|
|
3089
|
+
</WebGLContextPoolProvider>
|
|
3033
3090
|
)
|
|
3034
3091
|
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createContext, useContext, useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WebGL Context Pool — manages live WebGL slots for terminal widgets.
|
|
5
|
+
*
|
|
6
|
+
* Browsers cap WebGL contexts at ~8-16. This pool ensures only a subset
|
|
7
|
+
* of terminal widgets hold live ghostty-web renderers at any time.
|
|
8
|
+
* Offscreen terminals release their context and show a frozen snapshot.
|
|
9
|
+
*
|
|
10
|
+
* Architecture:
|
|
11
|
+
* - Lease-based: widgets register and receive live/frozen status
|
|
12
|
+
* - Priority: PINNED > VISIBLE > NEAR_VIEWPORT > OFFSCREEN
|
|
13
|
+
* - Hysteresis: 3s grace before evicting recently-visible terminals
|
|
14
|
+
* - Generation tokens prevent stale async opens after eviction
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Priority levels (higher = more important to keep live)
|
|
18
|
+
export const Priority = {
|
|
19
|
+
OFFSCREEN: 0,
|
|
20
|
+
NEAR_VIEWPORT: 1,
|
|
21
|
+
VISIBLE: 2,
|
|
22
|
+
PINNED: 3, // expanded, focused, or actively interacting
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEFAULT_MAX_LIVE = 6
|
|
26
|
+
const HYSTERESIS_MS = 3000
|
|
27
|
+
|
|
28
|
+
// ── Pool Engine (framework-agnostic) ─────────────────────────────────
|
|
29
|
+
|
|
30
|
+
class ContextPool {
|
|
31
|
+
constructor(maxLive = DEFAULT_MAX_LIVE) {
|
|
32
|
+
this._maxLive = maxLive
|
|
33
|
+
/** @type {Map<string, { priority: number, generation: number, live: boolean, lastVisible: number }>} */
|
|
34
|
+
this._slots = new Map()
|
|
35
|
+
this._listeners = new Set()
|
|
36
|
+
this._hysteresisTimers = new Map()
|
|
37
|
+
// Monotonic version counter — bumped on every state change so
|
|
38
|
+
// useSyncExternalStore detects updates via a new snapshot value.
|
|
39
|
+
this._version = 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Subscribe to pool state changes. Returns unsubscribe fn. */
|
|
43
|
+
subscribe(listener) {
|
|
44
|
+
this._listeners.add(listener)
|
|
45
|
+
return () => this._listeners.delete(listener)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_notify() {
|
|
49
|
+
this._version++
|
|
50
|
+
for (const fn of this._listeners) fn()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Register a widget. Returns its initial generation. */
|
|
54
|
+
register(widgetId, initialPriority = Priority.OFFSCREEN) {
|
|
55
|
+
if (this._slots.has(widgetId)) return this._slots.get(widgetId).generation
|
|
56
|
+
this._slots.set(widgetId, {
|
|
57
|
+
priority: initialPriority,
|
|
58
|
+
generation: 0,
|
|
59
|
+
live: false,
|
|
60
|
+
lastVisible: initialPriority >= Priority.VISIBLE ? Date.now() : 0,
|
|
61
|
+
})
|
|
62
|
+
this._recompute()
|
|
63
|
+
return 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Unregister a widget (on unmount). */
|
|
67
|
+
unregister(widgetId) {
|
|
68
|
+
this._slots.delete(widgetId)
|
|
69
|
+
const timer = this._hysteresisTimers.get(widgetId)
|
|
70
|
+
if (timer) { clearTimeout(timer); this._hysteresisTimers.delete(widgetId) }
|
|
71
|
+
this._recompute()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Update a widget's priority. Triggers recomputation. */
|
|
75
|
+
setPriority(widgetId, priority) {
|
|
76
|
+
const slot = this._slots.get(widgetId)
|
|
77
|
+
if (!slot) return
|
|
78
|
+
const prev = slot.priority
|
|
79
|
+
slot.priority = priority
|
|
80
|
+
|
|
81
|
+
// Track when widget was last visible/pinned for hysteresis
|
|
82
|
+
if (priority >= Priority.VISIBLE) {
|
|
83
|
+
slot.lastVisible = Date.now()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Immediate recompute when priority increases or widget is pinned
|
|
87
|
+
if (priority > prev || priority === Priority.PINNED) {
|
|
88
|
+
this._recompute()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// When priority drops (e.g. scrolled offscreen), use hysteresis
|
|
93
|
+
if (priority < prev && prev >= Priority.VISIBLE) {
|
|
94
|
+
const existing = this._hysteresisTimers.get(widgetId)
|
|
95
|
+
if (existing) clearTimeout(existing)
|
|
96
|
+
this._hysteresisTimers.set(widgetId, setTimeout(() => {
|
|
97
|
+
this._hysteresisTimers.delete(widgetId)
|
|
98
|
+
this._recompute()
|
|
99
|
+
}, HYSTERESIS_MS))
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this._recompute()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Get current state for a widget. */
|
|
107
|
+
getSlot(widgetId) {
|
|
108
|
+
return this._slots.get(widgetId) || null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Recompute which widgets should be live.
|
|
113
|
+
* Sorts by priority (desc), then by lastVisible (desc).
|
|
114
|
+
* Top N get live status; rest are frozen.
|
|
115
|
+
*/
|
|
116
|
+
_recompute() {
|
|
117
|
+
const entries = [...this._slots.entries()]
|
|
118
|
+
|
|
119
|
+
// Sort: pinned first, then by priority desc, then by recency
|
|
120
|
+
entries.sort(([, a], [, b]) => {
|
|
121
|
+
if (a.priority !== b.priority) return b.priority - a.priority
|
|
122
|
+
return b.lastVisible - a.lastVisible
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
let liveCount = 0
|
|
126
|
+
const changes = []
|
|
127
|
+
|
|
128
|
+
for (const [id, slot] of entries) {
|
|
129
|
+
// Pinned terminals always get a slot (they bypass the cap)
|
|
130
|
+
const shouldBeLive = slot.priority === Priority.PINNED || liveCount < this._maxLive
|
|
131
|
+
if (shouldBeLive) liveCount++
|
|
132
|
+
|
|
133
|
+
if (slot.live !== shouldBeLive) {
|
|
134
|
+
changes.push({ id, slot, shouldBeLive })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (changes.length === 0) return
|
|
139
|
+
|
|
140
|
+
for (const { slot, shouldBeLive } of changes) {
|
|
141
|
+
slot.live = shouldBeLive
|
|
142
|
+
if (!shouldBeLive) {
|
|
143
|
+
// Bump generation so stale async opens abort
|
|
144
|
+
slot.generation++
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this._notify()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── React Context ────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
const WebGLPoolContext = createContext(null)
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Provider that creates and owns the context pool.
|
|
158
|
+
* Place this around the canvas widget tree in CanvasPage.
|
|
159
|
+
*
|
|
160
|
+
* @param {{ maxLive?: number, children: React.ReactNode }} props
|
|
161
|
+
*/
|
|
162
|
+
export function WebGLContextPoolProvider({ maxLive = DEFAULT_MAX_LIVE, children }) {
|
|
163
|
+
// useMemo is correct here: the pool is a stable singleton for this provider's lifetime.
|
|
164
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
165
|
+
const pool = useMemo(() => new ContextPool(maxLive), [])
|
|
166
|
+
return (
|
|
167
|
+
<WebGLPoolContext.Provider value={pool}>
|
|
168
|
+
{children}
|
|
169
|
+
</WebGLPoolContext.Provider>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hook that manages a terminal widget's WebGL slot lifecycle.
|
|
175
|
+
*
|
|
176
|
+
* Returns:
|
|
177
|
+
* - `isLive`: whether this widget should create a live ghostty terminal
|
|
178
|
+
* - `generation`: counter that increments on each live→frozen transition;
|
|
179
|
+
* use as an effect dep to detect when to re-create the terminal
|
|
180
|
+
* - `setPriority(p)`: update this widget's priority
|
|
181
|
+
*
|
|
182
|
+
* @param {string} widgetId
|
|
183
|
+
* @returns {{ isLive: boolean, generation: number, setPriority: (p: number) => void }}
|
|
184
|
+
*/
|
|
185
|
+
export function useWebGLSlot(widgetId, initialPriority) {
|
|
186
|
+
const pool = useContext(WebGLPoolContext)
|
|
187
|
+
|
|
188
|
+
// Register on mount, unregister on unmount
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!pool) return
|
|
191
|
+
pool.register(widgetId, initialPriority)
|
|
192
|
+
return () => pool.unregister(widgetId)
|
|
193
|
+
}, [pool, widgetId]) // initialPriority intentionally excluded — only used on first register
|
|
194
|
+
|
|
195
|
+
// Subscribe to pool state for reactivity.
|
|
196
|
+
// getSnapshot returns the pool's version counter — a primitive that changes
|
|
197
|
+
// on every state mutation, satisfying useSyncExternalStore's identity check.
|
|
198
|
+
const subscribe = useCallback(
|
|
199
|
+
(cb) => pool ? pool.subscribe(cb) : () => {},
|
|
200
|
+
[pool],
|
|
201
|
+
)
|
|
202
|
+
const getSnapshot = useCallback(() => {
|
|
203
|
+
if (!pool) return -1
|
|
204
|
+
return pool._version
|
|
205
|
+
}, [pool])
|
|
206
|
+
|
|
207
|
+
// This triggers re-render whenever the pool version changes
|
|
208
|
+
useSyncExternalStore(subscribe, getSnapshot)
|
|
209
|
+
|
|
210
|
+
// Read the actual slot state (after subscription ensures freshness)
|
|
211
|
+
const slot = pool?.getSlot(widgetId)
|
|
212
|
+
|
|
213
|
+
const setPriority = useCallback(
|
|
214
|
+
(p) => pool?.setPriority(widgetId, p),
|
|
215
|
+
[pool, widgetId],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// When there's no pool provider (e.g. standalone usage), always be live
|
|
219
|
+
if (!pool || !slot) {
|
|
220
|
+
return { isLive: true, generation: 0, setPriority: () => {} }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
isLive: slot.live,
|
|
225
|
+
generation: slot.generation,
|
|
226
|
+
setPriority,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Hook for CanvasPage to batch-update visibility for all terminal widgets.
|
|
232
|
+
* Call this with the current viewport rect and widget positions.
|
|
233
|
+
*
|
|
234
|
+
* @returns {(viewportRect: {x:number,y:number,w:number,h:number}, widgets: Array<{id:string,type:string,position:{x:number,y:number},props?:object}>, selectedIds: Set<string>, expandedId: string|null) => void}
|
|
235
|
+
*/
|
|
236
|
+
export function usePoolVisibilityUpdater() {
|
|
237
|
+
const pool = useContext(WebGLPoolContext)
|
|
238
|
+
|
|
239
|
+
return useCallback((viewportRect, widgets, selectedIds, expandedId) => {
|
|
240
|
+
if (!pool) return
|
|
241
|
+
|
|
242
|
+
const NEAR_MARGIN = 400 // canvas-space pixels for "near viewport" zone
|
|
243
|
+
|
|
244
|
+
const terminalTypes = new Set(['terminal', 'agent', 'prompt'])
|
|
245
|
+
|
|
246
|
+
for (const w of widgets) {
|
|
247
|
+
if (!terminalTypes.has(w.type)) continue
|
|
248
|
+
|
|
249
|
+
// Determine priority
|
|
250
|
+
if (w.id === expandedId) {
|
|
251
|
+
pool.setPriority(w.id, Priority.PINNED)
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (selectedIds?.has(w.id)) {
|
|
256
|
+
pool.setPriority(w.id, Priority.PINNED)
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const wx = w.position?.x ?? 0
|
|
261
|
+
const wy = w.position?.y ?? 0
|
|
262
|
+
const ww = w.props?.width ?? 800
|
|
263
|
+
const wh = w.props?.height ?? 450
|
|
264
|
+
|
|
265
|
+
// Check overlap with viewport
|
|
266
|
+
const visible = rectsOverlap(
|
|
267
|
+
viewportRect.x, viewportRect.y, viewportRect.w, viewportRect.h,
|
|
268
|
+
wx, wy, ww, wh,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
if (visible) {
|
|
272
|
+
pool.setPriority(w.id, Priority.VISIBLE)
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check overlap with expanded viewport (near margin)
|
|
277
|
+
const near = rectsOverlap(
|
|
278
|
+
viewportRect.x - NEAR_MARGIN,
|
|
279
|
+
viewportRect.y - NEAR_MARGIN,
|
|
280
|
+
viewportRect.w + NEAR_MARGIN * 2,
|
|
281
|
+
viewportRect.h + NEAR_MARGIN * 2,
|
|
282
|
+
wx, wy, ww, wh,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
pool.setPriority(w.id, near ? Priority.NEAR_VIEWPORT : Priority.OFFSCREEN)
|
|
286
|
+
}
|
|
287
|
+
}, [pool])
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
|
|
291
|
+
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by
|
|
292
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, act } from '@testing-library/react'
|
|
3
|
+
import { WebGLContextPoolProvider, useWebGLSlot, usePoolVisibilityUpdater, Priority } from './WebGLContextPool.jsx'
|
|
4
|
+
|
|
5
|
+
function TestWidget({ widgetId, onSlot }) {
|
|
6
|
+
const slot = useWebGLSlot(widgetId)
|
|
7
|
+
onSlot?.(slot)
|
|
8
|
+
return <div data-testid={widgetId}>{slot.isLive ? 'live' : 'frozen'}</div>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function TestUpdater({ onUpdater }) {
|
|
12
|
+
const update = usePoolVisibilityUpdater()
|
|
13
|
+
onUpdater?.(update)
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('WebGLContextPool', () => {
|
|
18
|
+
it('grants live slots to widgets within the max limit', () => {
|
|
19
|
+
let slot1, slot2
|
|
20
|
+
render(
|
|
21
|
+
<WebGLContextPoolProvider maxLive={2}>
|
|
22
|
+
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
23
|
+
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
24
|
+
</WebGLContextPoolProvider>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// Both should be live since we're under the limit
|
|
28
|
+
expect(slot1.isLive).toBe(true)
|
|
29
|
+
expect(slot2.isLive).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('freezes excess widgets when over the limit', () => {
|
|
33
|
+
let slot1, slot2, slot3
|
|
34
|
+
render(
|
|
35
|
+
<WebGLContextPoolProvider maxLive={2}>
|
|
36
|
+
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
37
|
+
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
38
|
+
<TestWidget widgetId="t3" onSlot={(s) => { slot3 = s }} />
|
|
39
|
+
</WebGLContextPoolProvider>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const liveCount = [slot1, slot2, slot3].filter(s => s.isLive).length
|
|
43
|
+
const frozenCount = [slot1, slot2, slot3].filter(s => !s.isLive).length
|
|
44
|
+
|
|
45
|
+
expect(liveCount).toBe(2)
|
|
46
|
+
expect(frozenCount).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('always returns live when no provider is present', () => {
|
|
50
|
+
let slot
|
|
51
|
+
render(<TestWidget widgetId="t1" onSlot={(s) => { slot = s }} />)
|
|
52
|
+
expect(slot.isLive).toBe(true)
|
|
53
|
+
expect(slot.generation).toBe(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('prioritizes PINNED widgets over OFFSCREEN', () => {
|
|
57
|
+
let slot1, slot2, slot3
|
|
58
|
+
render(
|
|
59
|
+
<WebGLContextPoolProvider maxLive={2}>
|
|
60
|
+
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
61
|
+
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
62
|
+
<TestWidget widgetId="t3" onSlot={(s) => { slot3 = s }} />
|
|
63
|
+
</WebGLContextPoolProvider>
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Pin t3 — it should become live, evicting one of the others
|
|
67
|
+
act(() => { slot3.setPriority(Priority.PINNED) })
|
|
68
|
+
|
|
69
|
+
expect(slot3.isLive).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('PINNED widgets bypass the max limit', () => {
|
|
73
|
+
let slot1, slot2, slot3
|
|
74
|
+
render(
|
|
75
|
+
<WebGLContextPoolProvider maxLive={2}>
|
|
76
|
+
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
77
|
+
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
78
|
+
<TestWidget widgetId="t3" onSlot={(s) => { slot3 = s }} />
|
|
79
|
+
</WebGLContextPoolProvider>
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// Pin all three
|
|
83
|
+
act(() => {
|
|
84
|
+
slot1.setPriority(Priority.PINNED)
|
|
85
|
+
slot2.setPriority(Priority.PINNED)
|
|
86
|
+
slot3.setPriority(Priority.PINNED)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// All should be live because PINNED bypasses the cap
|
|
90
|
+
expect(slot1.isLive).toBe(true)
|
|
91
|
+
expect(slot2.isLive).toBe(true)
|
|
92
|
+
expect(slot3.isLive).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('tracks generation across live-frozen-live transitions', () => {
|
|
96
|
+
let slot1, slot2, slot3
|
|
97
|
+
render(
|
|
98
|
+
<WebGLContextPoolProvider maxLive={2}>
|
|
99
|
+
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
100
|
+
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
101
|
+
<TestWidget widgetId="t3" onSlot={(s) => { slot3 = s }} />
|
|
102
|
+
</WebGLContextPoolProvider>
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
// t3 starts frozen with generation 0 (never was live)
|
|
106
|
+
expect(slot3.isLive).toBe(false)
|
|
107
|
+
expect(slot3.generation).toBe(0)
|
|
108
|
+
|
|
109
|
+
// Pin t3 to make it live
|
|
110
|
+
act(() => { slot3.setPriority(Priority.PINNED) })
|
|
111
|
+
expect(slot3.isLive).toBe(true)
|
|
112
|
+
|
|
113
|
+
// Unpin t3 — it should be evicted and generation bumped
|
|
114
|
+
act(() => { slot3.setPriority(Priority.OFFSCREEN) })
|
|
115
|
+
// Hysteresis delays eviction; use fake timers if needed.
|
|
116
|
+
// For now, verify that generation bumps when eviction happens.
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('usePoolVisibilityUpdater updates priorities based on viewport', () => {
|
|
120
|
+
let slot1, slot2, updater
|
|
121
|
+
render(
|
|
122
|
+
<WebGLContextPoolProvider maxLive={1}>
|
|
123
|
+
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
124
|
+
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
125
|
+
<TestUpdater onUpdater={(u) => { updater = u }} />
|
|
126
|
+
</WebGLContextPoolProvider>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const widgets = [
|
|
130
|
+
{ id: 't1', type: 'terminal', position: { x: 100, y: 100 }, props: { width: 800, height: 450 } },
|
|
131
|
+
{ id: 't2', type: 'terminal', position: { x: 5000, y: 5000 }, props: { width: 800, height: 450 } },
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
// Viewport only covers t1
|
|
135
|
+
act(() => {
|
|
136
|
+
updater({ x: 0, y: 0, w: 1920, h: 1080 }, widgets, new Set(), null)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
expect(slot1.isLive).toBe(true)
|
|
140
|
+
expect(slot2.isLive).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('selected widgets get PINNED priority via visibility updater', () => {
|
|
144
|
+
let slot1, slot2, updater
|
|
145
|
+
render(
|
|
146
|
+
<WebGLContextPoolProvider maxLive={1}>
|
|
147
|
+
<TestWidget widgetId="t1" onSlot={(s) => { slot1 = s }} />
|
|
148
|
+
<TestWidget widgetId="t2" onSlot={(s) => { slot2 = s }} />
|
|
149
|
+
<TestUpdater onUpdater={(u) => { updater = u }} />
|
|
150
|
+
</WebGLContextPoolProvider>
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const widgets = [
|
|
154
|
+
{ id: 't1', type: 'terminal', position: { x: 100, y: 100 }, props: { width: 800, height: 450 } },
|
|
155
|
+
{ id: 't2', type: 'terminal', position: { x: 5000, y: 5000 }, props: { width: 800, height: 450 } },
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
// t2 is offscreen but selected — should be pinned and live
|
|
159
|
+
act(() => {
|
|
160
|
+
updater({ x: 0, y: 0, w: 1920, h: 1080 }, widgets, new Set(['t2']), null)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
expect(slot2.isLive).toBe(true)
|
|
164
|
+
})
|
|
165
|
+
})
|