@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.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/TriggerGauge-HIPRGEWJ.mjs +117 -0
- package/dist/index.d.mts +127 -0
- package/dist/index.d.ts +127 -0
- package/dist/index.js +649 -0
- package/dist/index.mjs +475 -0
- package/package.json +103 -0
- package/src/InlineColorPicker.tsx +183 -0
- package/src/PopoverBody.tsx +131 -0
- package/src/PressAwayOverlay.tsx +20 -0
- package/src/ReadyButton.tsx +43 -0
- package/src/SectionedDropdown.tsx +387 -0
- package/src/TriggerGauge.tsx +285 -0
- package/src/TriggerGaugeHost.tsx +27 -0
- package/src/fonts.ts +6 -0
- package/src/index.ts +18 -0
- package/src/loadSkiaWeb.ts +14 -0
- package/src/useAutoAlign.ts +88 -0
- package/src/usePopoverHost.ts +24 -0
|
@@ -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
|
+
}
|