@tastic/hud 0.1.0

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.
@@ -0,0 +1,285 @@
1
+ import { Canvas, Path, Skia } from '@shopify/react-native-skia'
2
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react'
3
+ import { StyleSheet, View } from 'react-native'
4
+ import { Easing, useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated'
5
+
6
+ export interface TriggerGaugeProps {
7
+ // One dash per option — a single-select section's arc (grid size, speed, trail speed, CPU
8
+ // difficulty) lights exactly one dash at the selected index; a multi-select section's (powerups)
9
+ // lights one dash per enabled option, independently. Fewer than 2 renders nothing — a one-option
10
+ // "choice" has nothing for an arc to distinguish.
11
+ segments: number
12
+ litIndices: number[]
13
+ size: number
14
+ accentColor: string
15
+ mutedColor: string
16
+ // Each dash's own arc length in px (at this gauge's radius) — how much of the arc it visually
17
+ // covers. Omit to auto-size (see DASH_GAP_PX below) so it reads as a true dashed line — each dash
18
+ // fills its own slot along the arc minus a small constant gap, regardless of segment count. Pass
19
+ // an explicit px value to override that with a fixed size instead (won't auto-adjust for segment
20
+ // count, so a large fixed value can visually merge adjacent dashes on a high-segment gauge like
21
+ // powerups — unlike the old straight-bar version, an oversized arc here just gets long, not
22
+ // distorted, since it's a real curve rather than an approximation of one).
23
+ dashWidth?: number
24
+ // Where the arc begins, in degrees clockwise from 12 o'clock (matching Skia's own angle
25
+ // convention once shifted — see the -90° conversion below). Traversal from startDeg to endDeg is
26
+ // always clockwise, so which of the two possible arcs (the short way or the long way around) you
27
+ // get depends on how far clockwise endDeg sits from startDeg — swap the two values to flip
28
+ // direction. Defaults to 6 o'clock — see endDeg for why that default is a full circle rather than
29
+ // any particular arc.
30
+ startDeg?: number
31
+ // Where the arc ends — see startDeg. Defaults to whatever startDeg resolved to (its own explicit
32
+ // value if you passed one, otherwise 6 o'clock), not some other fixed clock position — so passing
33
+ // only startDeg still gets you a full circle, just seamed wherever you started it, rather than an
34
+ // arbitrary partial arc between your startDeg and an unrelated default endDeg. Pass a distinct
35
+ // endDeg to actually clamp the sweep and carve out a gap — e.g. 210 (7 o'clock) with endDeg 150
36
+ // (5 o'clock) is a 300° arc, the long way over the top through 12, leaving a 60° gap under the
37
+ // bottom through 6. Whenever the span is carved down from a full circle like that, it's divided
38
+ // into exactly `segments` equal shares (see the render below) — a 2-way split gets two equal
39
+ // shares meeting at the sweep's midpoint, a 3-way split gets three, and so on for any segment
40
+ // count — every option gets a true, equal fraction of the visible arc. Segment order follows the
41
+ // same clockwise sweep, so index 0 sits nearest startDeg and the last index nearest endDeg.
42
+ endDeg?: number
43
+ }
44
+
45
+ // Stroke width for an unlit dash — thin, so it reads as a faint outline rather than competing with
46
+ // the lit one for attention.
47
+ const UNLIT_THICKNESS = 3
48
+ // Stroke width for a lit dash — noticeably thicker than UNLIT_THICKNESS, so the active option looks
49
+ // like it's actually "filled in" rather than just recolored.
50
+ const LIT_THICKNESS = 7
51
+ // Gap between consecutive dashes when auto-sizing (dashWidth omitted) — constant regardless of
52
+ // segment count, which is what keeps the arc reading as one dashed line instead of a solid merged
53
+ // ring (many segments) — sized generously so unlit and lit dashes read as clearly separate marks
54
+ // rather than blending into one ring at a glance.
55
+ const DASH_GAP_PX = 9
56
+ // Gap between the trigger's own icon circle and the arc — small, so the dashes sit close in around
57
+ // the icon rather than floating well outside its silhouette.
58
+ const ARC_GAP = 2
59
+ // Baseline for startDeg when the caller passes neither prop at all — arbitrary in principle (any
60
+ // value gives a full circle once endDeg defaults to match it, see the component signature below),
61
+ // picked as 6 o'clock so the seam sits tucked underneath the trigger rather than across its top.
62
+ const DEFAULT_START_DEG = 180 // 6 o'clock
63
+ // Floor so a many-segment gauge (or a very small dashWidth override) never collapses a dash's own
64
+ // sweep down to something too thin to register as a stroke.
65
+ const MIN_DASH_ANGLE_DEG = 4
66
+ // One-shot "gauge waking up" reveal on mount — each dash sweeps in from nothing rather than popping
67
+ // straight into existence, staggered by index so the reveal visibly travels along the arc instead of
68
+ // every dash appearing at once. Purely cosmetic, and runs once per mount only (see the effect below).
69
+ const MOUNT_DURATION_MS = 450
70
+ const MOUNT_STAGGER_MS = 40
71
+ // Selection-change transition — separate from the one-shot mount reveal above, and re-triggered
72
+ // every time litIndices actually changes after that. Quick and snappy rather than a slow drift, so
73
+ // it reads as "the gauge just updated" without making the picker feel sluggish to use.
74
+ const SELECTION_TRANSITION_DURATION_MS = 260
75
+
76
+ // Arc of dashes drawn around a SectionedDropdown trigger, purely decorative-informational — the
77
+ // trigger's own icon stays exactly as it is (see SectionedDropdown's triggerIcon), this just frames
78
+ // it with an at-a-glance read of *which* option is active without opening the popover. Each dash is
79
+ // a real Skia arc segment (Path.addArc), not a straight bar approximating one — a straight bar wide
80
+ // enough to look bold started visibly crossing its neighbors on a sparse (e.g. 3-option) gauge,
81
+ // since a chord that long diverges a lot from the circle it's meant to trace; an actual arc stays on
82
+ // the circle at any width. Skia measures angles clockwise from 3 o'clock, so a center angle here
83
+ // (measured clockwise from 12, like startDeg/endDeg) is shifted by -90° before being handed to
84
+ // addArc.
85
+ export function TriggerGauge({ segments, litIndices, size, accentColor, mutedColor, dashWidth, startDeg = DEFAULT_START_DEG, endDeg = startDeg }: TriggerGaugeProps) {
86
+ // Every hook below runs unconditionally, ahead of the segments<2 bail-out further down (rules of
87
+ // hooks) — harmless when it fires, since the resulting paths just never get read.
88
+ const totalDurationMs = MOUNT_DURATION_MS + Math.max(0, segments - 1) * MOUNT_STAGGER_MS
89
+ const mountProgress = useSharedValue(0)
90
+ useLayoutEffect(() => {
91
+ mountProgress.value = withTiming(1, { duration: totalDurationMs, easing: Easing.out(Easing.cubic) })
92
+ // Runs once per mount only — a "the gauge just appeared" cue, not tied to which option is
93
+ // selected, so this deliberately ignores changes to props that follow.
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps
95
+ }, [])
96
+
97
+ // Clockwise distance from startDeg to endDeg — e.g. 210 (7 o'clock) to 150 (5 o'clock) is 300°,
98
+ // the long way over the top, not the 60° short way under the bottom that a naive `endDeg -
99
+ // startDeg` would give as a negative number whenever endDeg is numerically smaller. Divided evenly
100
+ // across every segment (not every *gap* between segments — see step below) so each option gets a
101
+ // true equal fraction of the visible arc regardless of segment count, which is what actually keeps
102
+ // a 2-segment gauge from reading differently proportioned than a 3- or 4-segment one. The %360
103
+ // double-mod can't distinguish "0° apart" from "360° apart" (both reduce to 0), and a 0° sweep is
104
+ // never a useful gauge — every dash would collapse onto the same point — so a 0 result is treated
105
+ // as the far more likely intent, a full unbroken circle, rather than a degenerate one.
106
+ const rawSweepDeg = (((endDeg - startDeg) % 360) + 360) % 360
107
+ const totalSweepDeg = rawSweepDeg === 0 ? 360 : rawSweepDeg
108
+ // Each segment's own angular share of totalSweepDeg — doubles as both "this dash's width before
109
+ // the gap is subtracted" (see autoDashAngleDeg below) and "the spacing between consecutive
110
+ // centers" (see centerDeg), since a set of equal, contiguous shares naturally has both properties
111
+ // at once.
112
+ const step = segments > 0 ? totalSweepDeg / segments : 0
113
+ // Segment i's own center sits half a share clockwise from startDeg, then a further whole share per
114
+ // preceding index — i.e. the middle of the i-th equal slice, not at either of its edges. Segment
115
+ // 0 is the first slice *after* startDeg (never sits exactly on startDeg itself), and the last
116
+ // segment's slice ends exactly on endDeg. Index increases clockwise — left to right across the
117
+ // top — matching normal reading order.
118
+ const centerDeg = useCallback((i: number) => startDeg + (i + 0.5) * step, [startDeg, step])
119
+ // A single-select trigger (grid size, speed, trail speed, CPU difficulty) always has exactly one
120
+ // lit index — a multi-select one (powerups) can have any count, including exactly 1, so this is a
121
+ // structural property of which *kind* of section this gauge belongs to, not something that flips
122
+ // render to render in practice.
123
+ const singleSelect = litIndices.length === 1
124
+
125
+ // The lit arc's own center angle, for the singleSelect case — tracked continuously rather than
126
+ // computed fresh from a "from index" + 0..1 progress pair, specifically so a rapid follow-up
127
+ // selection can *retarget* an already-in-flight rotation instead of snapping to a fixed start
128
+ // angle first. withTiming does this automatically when you assign a new target to a shared value
129
+ // that's already mid-animation: it interpolates from wherever the value actually is right now, not
130
+ // from the animation's original start point. Resetting the value before restarting the animation
131
+ // (this file's own previous approach) throws that away — every follow-up transition would snap the
132
+ // arc to the *previous* transition's fixed departure angle before rotating on, which read as a
133
+ // glitch/teleport on anything but the very first change. Initial value is set directly, before any
134
+ // animation exists, so the very first mount doesn't rotate in from nowhere — see the mount reveal
135
+ // (sweep width, not angle) above for that.
136
+ const angleDeg = useSharedValue(centerDeg(litIndices[0] ?? 0))
137
+
138
+ // Selection-change transition state, used only for the multi-select (non-singleSelect) case now —
139
+ // singleSelect handles its own transition via angleDeg above. `transitionFrom` is whatever was lit
140
+ // just before the most recent change — detected by comparing litIndicesKey against lastKeyRef,
141
+ // which trails one change behind (see lastIndicesRef.current below). This lives in
142
+ // useLayoutEffect rather than a plain useEffect specifically so it fires — and, since
143
+ // setTransitionFrom during it triggers a synchronous re-render, *resolves* — before the browser
144
+ // ever paints the frame it was scheduled from: a plain useEffect can run after paint, letting React
145
+ // commit one real frame with the new litIndices against the still-stale transitionFrom/
146
+ // selectionProgress first, which reads as a flash. Refs, not state, for the tracking itself —
147
+ // react-hooks/refs correctly flags reading or writing a ref during the render body (a discarded/
148
+ // replayed render can leave a ref mutated without ever committing, corrupting this tracking in a
149
+ // way that reproduces intermittently rather than every time), so this all needs to live inside an
150
+ // effect, not inline in the component body. The very first mount never triggers this: lastKeyRef/
151
+ // transitionFrom's own initial state start out equal to the first litIndices this instance ever
152
+ // sees, so mount's own reveal is entirely mountProgress's job, not this one's.
153
+ const litIndicesKey = [...litIndices].sort((a, b) => a - b).join(',')
154
+ const [transitionFrom, setTransitionFrom] = useState(litIndices)
155
+ const transitionFromKey = [...transitionFrom].sort((a, b) => a - b).join(',')
156
+ const lastKeyRef = useRef(litIndicesKey)
157
+ const lastIndicesRef = useRef(litIndices)
158
+ const selectionProgress = useSharedValue(1)
159
+ useLayoutEffect(() => {
160
+ if (litIndicesKey !== lastKeyRef.current) {
161
+ if (singleSelect && lastIndicesRef.current.length === 1) {
162
+ angleDeg.value = withTiming(centerDeg(litIndices[0]), { duration: SELECTION_TRANSITION_DURATION_MS, easing: Easing.out(Easing.cubic) })
163
+ } else {
164
+ setTransitionFrom(lastIndicesRef.current)
165
+ selectionProgress.value = 0
166
+ selectionProgress.value = withTiming(1, { duration: SELECTION_TRANSITION_DURATION_MS, easing: Easing.out(Easing.cubic) })
167
+ }
168
+ lastKeyRef.current = litIndicesKey
169
+ }
170
+ lastIndicesRef.current = litIndices
171
+ }, [litIndicesKey, litIndices, selectionProgress, angleDeg, singleSelect, centerDeg])
172
+
173
+ const radius = size / 2 + ARC_GAP
174
+ // step is always >= 0 now (totalSweepDeg's double-mod guarantees it, unlike the old signed
175
+ // sweepDeg this replaced), so this no longer needs Math.abs — kept as its own name rather than
176
+ // reusing `step` directly since this one specifically means "before the gap is subtracted."
177
+ const slotDeg = step
178
+ // A px gap converted to degrees at this radius (arc length = radius * angleInRadians), so the gap
179
+ // between dashes reads as visually constant regardless of radius or segment count.
180
+ const gapDeg = (DASH_GAP_PX / radius) * (180 / Math.PI)
181
+ // No upper clamp: each dash fills its own equal slot minus the constant gap, whatever that share
182
+ // works out to be, so a 2-segment gauge tiles as fully as a 4-segment one instead of stopping at
183
+ // some fixed width and leaving the rest of a large share visibly empty (that mismatch is exactly
184
+ // what a fixed ceiling used to cause — same segment count, same full circle, but only some of
185
+ // them read as "divided all the way around" and others didn't).
186
+ const autoDashAngleDeg = Math.max(MIN_DASH_ANGLE_DEG, slotDeg - gapDeg)
187
+ const dashAngleDeg = dashWidth != null ? Math.max(MIN_DASH_ANGLE_DEG, (dashWidth / radius) * (180 / Math.PI)) : autoDashAngleDeg
188
+
189
+ const diameter = radius * 2
190
+ // LIT_THICKNESS/2 padding on every side (the thicker of the two strokes) so a full-width stroke
191
+ // never clips at the canvas edge.
192
+ const canvasSize = diameter + LIT_THICKNESS
193
+ const oval = Skia.XYWHRect(LIT_THICKNESS / 2, LIT_THICKNESS / 2, diameter, diameter)
194
+
195
+ // One combined path per style group (lit vs unlit) rather than one path per dash — a Path strokes
196
+ // as a single color/width, so dashes sharing a style share a path; this also keeps the number of
197
+ // useDerivedValue calls fixed at two regardless of segment count, which a per-dash hook would
198
+ // violate (hook counts can't vary across renders). Always reflects the FINAL/target litIndices,
199
+ // dim — including whatever's mid-"leaving" in litPath below, so the dim base is already sitting
200
+ // there the instant a lit dash starts shrinking off of it, rather than popping in only once the
201
+ // shrink finishes.
202
+ const unlitPath = useDerivedValue(() => {
203
+ const path = Skia.Path.Make()
204
+ for (let i = 0; i < segments; i++) {
205
+ if (litIndices.includes(i)) continue
206
+ let local = 1
207
+ if (mountProgress.value < 1) {
208
+ const startFrac = (i * MOUNT_STAGGER_MS) / totalDurationMs
209
+ const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs
210
+ local = Math.min(1, Math.max(0, (mountProgress.value - startFrac) / (endFrac - startFrac)))
211
+ }
212
+ const sweep = dashAngleDeg * local
213
+ if (sweep <= 0) continue
214
+ // Recomputed inline (not calling the outer centerDeg helper) — this runs inside a
215
+ // useDerivedValue worklet, and the helper above is a plain JS closure, not itself workletized.
216
+ const dashCenterDeg = startDeg + (i + 0.5) * step
217
+ path.addArc(oval, dashCenterDeg - 90 - sweep / 2, sweep)
218
+ }
219
+ return path
220
+ // litIndicesKey, not litIndices — the array itself is a brand-new reference on every render
221
+ // (including ones triggered by a totally unrelated sibling gauge or settings field), which would
222
+ // otherwise force this worklet to rebuild on every such render. The stable string key only
223
+ // actually changes when the *content* does, so an unrelated re-render leaves the already-running
224
+ // animation alone instead of restarting/glitching it.
225
+ }, [segments, litIndicesKey, step, startDeg, dashAngleDeg, totalDurationMs, oval.x, oval.y, oval.width, oval.height])
226
+
227
+ const litPath = useDerivedValue(() => {
228
+ const path = Skia.Path.Make()
229
+ // The common case — a single-select trigger swapping from one tier to another — is just one
230
+ // full-width arc sitting at angleDeg's current value, which useLayoutEffect above retargets
231
+ // (never resets) on every change; this alone gives a real rotating handoff, including smoothly
232
+ // redirecting mid-rotation if another change interrupts it. Only reached once the mount reveal
233
+ // is done — before that, the sweep-width stagger below owns the reveal instead.
234
+ if (mountProgress.value >= 1 && singleSelect) {
235
+ path.addArc(oval, angleDeg.value - 90 - dashAngleDeg / 2, dashAngleDeg)
236
+ return path
237
+ }
238
+
239
+ for (let i = 0; i < segments; i++) {
240
+ const isLitNow = litIndices.includes(i)
241
+ const wasLit = transitionFrom.includes(i)
242
+ if (!isLitNow && !wasLit) continue
243
+ let local
244
+ if (mountProgress.value < 1) {
245
+ if (!isLitNow) continue
246
+ const startFrac = (i * MOUNT_STAGGER_MS) / totalDurationMs
247
+ const endFrac = startFrac + MOUNT_DURATION_MS / totalDurationMs
248
+ local = Math.min(1, Math.max(0, (mountProgress.value - startFrac) / (endFrac - startFrac)))
249
+ } else if (isLitNow === wasLit) {
250
+ local = 1 // steady — lit before and after (or the mount-only branch above already handled unlit)
251
+ } else if (isLitNow) {
252
+ local = selectionProgress.value // entering — grows in
253
+ } else {
254
+ local = 1 - selectionProgress.value // leaving — shrinks away
255
+ }
256
+ const sweep = dashAngleDeg * local
257
+ if (sweep <= 0) continue
258
+ // Recomputed inline (not calling the outer centerDeg helper) — this runs inside a
259
+ // useDerivedValue worklet, and the helper above is a plain JS closure, not itself workletized.
260
+ const dashCenterDeg = startDeg + (i + 0.5) * step
261
+ path.addArc(oval, dashCenterDeg - 90 - sweep / 2, sweep)
262
+ }
263
+ return path
264
+ // litIndicesKey/transitionFromKey, not the arrays themselves — see unlitPath's identical comment.
265
+ }, [segments, litIndicesKey, transitionFromKey, singleSelect, step, startDeg, dashAngleDeg, totalDurationMs, oval.x, oval.y, oval.width, oval.height])
266
+
267
+ if (segments < 2) return null
268
+
269
+ return (
270
+ <View style={[styles.container, { height: size, width: size }]} pointerEvents='none'>
271
+ <Canvas style={{ height: canvasSize, width: canvasSize }}>
272
+ <Path path={unlitPath} style='stroke' strokeWidth={UNLIT_THICKNESS} strokeCap='round' color={mutedColor} opacity={0.35} />
273
+ <Path path={litPath} style='stroke' strokeWidth={LIT_THICKNESS} strokeCap='round' color={accentColor} opacity={1} />
274
+ </Canvas>
275
+ </View>
276
+ )
277
+ }
278
+
279
+ const styles = StyleSheet.create({
280
+ container: {
281
+ alignItems: 'center',
282
+ justifyContent: 'center',
283
+ position: 'absolute'
284
+ }
285
+ })
@@ -0,0 +1,27 @@
1
+ import { lazy, Suspense } from 'react'
2
+
3
+ import { loadSkiaWeb } from './loadSkiaWeb'
4
+ import { TriggerGaugeProps } from './TriggerGauge'
5
+
6
+ // TriggerGauge.tsx's `Skia` import binds to global.CanvasKit at module-evaluation time, which on web
7
+ // only exists once loadSkiaWeb()'s WASM fetch resolves (see that file's own comment) — a plain
8
+ // `import { TriggerGauge } from './TriggerGauge'` at the top of this file would already have
9
+ // evaluated that binding, against a not-yet-loaded, undefined CanvasKit, before anything in a
10
+ // consuming app renders at all, since bundlers evaluate the whole static import graph up front. This
11
+ // file therefore never imports TriggerGauge itself except as a type (erased at compile time,
12
+ // carrying no runtime module reference) — `lazy()` is what actually defers the real import to
13
+ // runtime, after loadSkiaWeb() resolves. Always lazy, on every platform, rather than a native/web
14
+ // branch here: this package builds to a single bundled dist file (see loadSkiaWeb's own comment on
15
+ // why), so there's no reliable build-time way to give native a plain, non-lazy passthrough instead —
16
+ // loadSkiaWeb() itself already resolves immediately on native, so the Suspense boundary there just
17
+ // clears on the next tick, not a perceptible delay. The Suspense fallback renders nothing, so a
18
+ // consumer's trigger icon still shows immediately and the gauge ring just pops in a beat later.
19
+ const LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import('./TriggerGauge').then((m) => ({ default: m.TriggerGauge }))))
20
+
21
+ export default function TriggerGaugeHost(props: TriggerGaugeProps) {
22
+ return (
23
+ <Suspense fallback={null}>
24
+ <LazyTriggerGauge {...props} />
25
+ </Suspense>
26
+ )
27
+ }
package/src/fonts.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { Platform } from 'react-native'
2
+
3
+ // System monospace — every component here defaults its labels to this unless a consumer passes
4
+ // its own `labelFontFamily`, so a game with its own branded/registered font can override it
5
+ // without this package needing to know that font exists.
6
+ export const MONO_FONT = Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' })
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export { MONO_FONT } from './fonts'
2
+ export { InlineColorPicker } from './InlineColorPicker'
3
+ export { loadSkiaWeb } from './loadSkiaWeb'
4
+ export { PopoverBody } from './PopoverBody'
5
+ export { PressAwayOverlay } from './PressAwayOverlay'
6
+ export { ReadyButton } from './ReadyButton'
7
+ export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
8
+ // TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
9
+ // though a native-only consumer could safely skip TriggerGaugeHost's Suspense/lazy-load
10
+ // indirection. A value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import
11
+ // into a chunk that this entry point imports eagerly at the top level — since that chunk is then
12
+ // shared with TriggerGaugeHost's lazy dynamic import too, it defeats TriggerGaugeHost's own
13
+ // deferred loading for every consumer, on every platform, not just the ones reaching for the raw
14
+ // component. TriggerGaugeHost is the only supported way to render this.
15
+ export type { TriggerGaugeProps } from './TriggerGauge'
16
+ export { default as TriggerGaugeHost } from './TriggerGaugeHost'
17
+ export { type PopoverAlign, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
18
+ export { type PopoverHost, usePopoverHost } from './usePopoverHost'
@@ -0,0 +1,14 @@
1
+ import { Platform } from 'react-native'
2
+
3
+ // Skia's web target renders through CanvasKit, a WASM build of Skia fetched at runtime — nothing
4
+ // using @shopify/react-native-skia's React components can draw a single frame until this resolves.
5
+ // Skia on native (via JSI) is ready as soon as the app's native module is linked, so this only
6
+ // actually does anything on web — a Platform.OS check rather than a `.web.ts` sibling file, since
7
+ // this package builds to a single bundled dist file (see package.json's `build` script): a bundler
8
+ // that pre-flattens the whole module graph itself (tsup/esbuild) never sees or swaps in a `.web.ts`
9
+ // variant the way a consuming app's own Metro bundler would, so the split has to be a runtime
10
+ // branch that survives being bundled, not a build-time file choice that wouldn't.
11
+ export function loadSkiaWeb(): Promise<void> {
12
+ if (Platform.OS !== 'web') return Promise.resolve()
13
+ return import('@shopify/react-native-skia/src/web').then((m) => m.LoadSkiaWeb())
14
+ }
@@ -0,0 +1,88 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { useWindowDimensions, View } from 'react-native'
3
+
4
+ // Minimum breathing room between a popover's outer edge and the screen edge — matches the general
5
+ // "don't butt content right up against the bezel" margin used elsewhere in this app.
6
+ const EDGE_MARGIN = 12
7
+
8
+ export type PopoverAlign = 'left' | 'right' | 'center'
9
+ export type PopoverVerticalAlign = 'below' | 'above'
10
+
11
+ // Measures a trigger's actual on-screen position (via measureInWindow, which resolves post-
12
+ // transform — this is what makes it correct even for a trigger sitting inside a 180°-rotated zone,
13
+ // unlike a plain onLayout which only ever reports pre-transform local coordinates) and picks
14
+ // whichever alignment keeps a popover of `contentWidth`×`contentHeight` from overflowing a screen
15
+ // edge — horizontally (left/right/center, preferring center) and vertically (below/above,
16
+ // preferring below — the usual case — but flipping to above when the trigger doesn't have enough
17
+ // room underneath it, which is what a short landscape screen with the trigger row near the top
18
+ // actually runs into).
19
+ //
20
+ // Also returns `maxHeight`: the actual room available in whichever vertical direction got picked.
21
+ // Flipping above-vs-below only ever picks the *better* side — on a screen short enough that neither
22
+ // side has room for the whole thing (the same landscape case, just more cramped), that's still not
23
+ // enough on its own. The caller is expected to cap its content to this and let it scroll instead of
24
+ // silently overflowing past the screen edge — this hook only decides where the popover opens, not
25
+ // how its content copes with running out of room, since that's presentation-specific (a menu scrolls
26
+ // its rows; other content might reasonably do something else).
27
+ //
28
+ // Re-measures on every open (not continuously) — the trigger's screen position can't change while
29
+ // its own popover is showing anyway, so there's nothing to gain from tracking it beyond that.
30
+ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number) {
31
+ const triggerRef = useRef<View>(null)
32
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions()
33
+ const [align, setAlign] = useState<PopoverAlign>('center')
34
+ const [verticalAlign, setVerticalAlign] = useState<PopoverVerticalAlign>('below')
35
+ const [maxHeight, setMaxHeight] = useState<number>(contentHeight)
36
+ // False from the instant `open` goes true until this open's own measurement actually lands —
37
+ // measureInWindow resolves via a native callback, not synchronously, so there's a real gap where
38
+ // `align`/`verticalAlign` still hold whatever a *previous* open last measured (or the plain
39
+ // 'center'/'below' guess, the very first time). Left ungated, the popover would mount and paint at
40
+ // that stale/guessed position for one frame, then visibly jump the instant the real measurement
41
+ // lands — the caller is expected to hold the popover itself hidden (not just unmeasured content)
42
+ // until this flips true, which is what actually avoids the flash rather than just relocating it.
43
+ const [measured, setMeasured] = useState(false)
44
+ // Detects the false->true edge of `open` *during render* (React's "adjusting state during render"
45
+ // pattern) rather than in the effect below, specifically so `measured` is already false by the
46
+ // time this same render commits — an effect-based reset would still let one frame paint at the
47
+ // stale/guessed alignment before the reset (and the eventual re-measurement) caught up.
48
+ const [prevOpen, setPrevOpen] = useState(open)
49
+ if (open !== prevOpen) {
50
+ setPrevOpen(open)
51
+ if (open) setMeasured(false)
52
+ }
53
+
54
+ const measure = useCallback(() => {
55
+ triggerRef.current?.measureInWindow((x, y, triggerWidth, triggerHeight) => {
56
+ const centerX = x + triggerWidth / 2
57
+ const overflowsRight = centerX + contentWidth / 2 > windowWidth - EDGE_MARGIN
58
+ const overflowsLeft = centerX - contentWidth / 2 < EDGE_MARGIN
59
+ // Both directions overflowing means the popover is simply wider than the screen has room for
60
+ // either way — 'center' is the least-bad choice there (symmetric clipping beats asymmetric).
61
+ if (overflowsRight && !overflowsLeft) setAlign('right')
62
+ else if (overflowsLeft && !overflowsRight) setAlign('left')
63
+ else setAlign('center')
64
+
65
+ // Below is the default/preferred direction — only flips to above when there's genuinely more
66
+ // room that way, not merely whenever below is imperfect, so a popover close to fitting either
67
+ // way doesn't flip-flop from a one-pixel margin difference.
68
+ const roomBelow = windowHeight - (y + triggerHeight) - EDGE_MARGIN
69
+ const roomAbove = y - EDGE_MARGIN
70
+ const above = contentHeight > roomBelow && roomAbove > roomBelow
71
+ setVerticalAlign(above ? 'above' : 'below')
72
+ setMaxHeight(Math.max(above ? roomAbove : roomBelow, 0))
73
+ setMeasured(true)
74
+ })
75
+ }, [contentWidth, contentHeight, windowWidth, windowHeight])
76
+
77
+ // Re-measures whenever `open` flips true, and also if `measure` itself changes identity (i.e.
78
+ // contentWidth/contentHeight/windowWidth/windowHeight changed) while already open — e.g. a live
79
+ // window resize — so it never goes stale while the popover is actually showing. `measured` only
80
+ // resets on a genuine (re)open, via the render-phase check above — not here on every re-measure,
81
+ // so an already-visible popover never flickers hidden again just because the window resized
82
+ // under it.
83
+ useEffect(() => {
84
+ if (open) measure()
85
+ }, [open, measure])
86
+
87
+ return { align, maxHeight, measured, triggerRef, verticalAlign }
88
+ }
@@ -0,0 +1,24 @@
1
+ import { useCallback, useState } from 'react'
2
+
3
+ export interface PopoverHost {
4
+ openId: string | null
5
+ toggle: (id: string) => void
6
+ close: () => void
7
+ }
8
+
9
+ // Shared open/closed state for a small group of sibling popovers (e.g. one player panel's color
10
+ // picker and the shared controls' dropdowns) so opening one closes any other already open in the
11
+ // same group. Give each player's panel its own instance so opening a popover in one player's panel
12
+ // never affects another player's — that independence is what lets two people edit their own setup
13
+ // at once on a split screen.
14
+ export function usePopoverHost(): PopoverHost {
15
+ const [openId, setOpenId] = useState<string | null>(null)
16
+
17
+ const toggle = useCallback((id: string) => {
18
+ setOpenId((prev) => (prev === id ? null : id))
19
+ }, [])
20
+
21
+ const close = useCallback(() => setOpenId(null), [])
22
+
23
+ return { openId, toggle, close }
24
+ }