@tastic/hud 0.6.0 → 0.8.1
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/README.md +48 -8
- package/dist/SkiaGate.d.mts +13 -0
- package/dist/SkiaGate.d.ts +13 -0
- package/dist/SkiaGate.js +45 -0
- package/dist/SkiaGate.mjs +6 -0
- package/dist/chunk-XEOFR236.mjs +21 -0
- package/dist/index.d.mts +124 -75
- package/dist/index.d.ts +124 -75
- package/dist/index.js +476 -328
- package/dist/index.mjs +440 -310
- package/package.json +13 -6
- package/src/BaseSettingsDialog.tsx +9 -6
- package/src/BaseStatsScreen.tsx +10 -7
- package/src/ConfirmDialog.tsx +104 -0
- package/src/ControlSchemePicker.tsx +53 -0
- package/src/CornerActionButtons.tsx +10 -2
- package/src/CornerStatusBadges.tsx +87 -0
- package/src/InlineColorPicker.tsx +10 -4
- package/src/SectionedDropdown.tsx +17 -3
- package/src/SkiaGate.tsx +45 -0
- package/src/SkiaGate.web.tsx +9 -0
- package/src/TriggerGaugeHost.tsx +12 -20
- package/src/index.ts +11 -7
- package/src/useAutoAlign.ts +58 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tastic/hud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Visual component kit for local-multiplayer React Native games: inline (non-modal) popovers, dropdowns, color pickers, gauges, ready buttons, and dialogs, built to stay scoped to one player's own zone on a shared screen",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react-native",
|
|
@@ -33,6 +33,13 @@
|
|
|
33
33
|
"types": "./dist/index.d.ts",
|
|
34
34
|
"import": "./dist/index.mjs",
|
|
35
35
|
"require": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./skia-gate": {
|
|
38
|
+
"react-native": "./src/SkiaGate.tsx",
|
|
39
|
+
"browser": "./src/SkiaGate.web.tsx",
|
|
40
|
+
"types": "./dist/SkiaGate.d.ts",
|
|
41
|
+
"import": "./dist/SkiaGate.mjs",
|
|
42
|
+
"require": "./dist/SkiaGate.js"
|
|
36
43
|
}
|
|
37
44
|
},
|
|
38
45
|
"main": "dist/index.js",
|
|
@@ -63,14 +70,14 @@
|
|
|
63
70
|
},
|
|
64
71
|
"prettier": "@infinitetoken/eslint-config/prettier",
|
|
65
72
|
"devDependencies": {
|
|
66
|
-
"@infinitetoken/eslint-config": "0.2.0",
|
|
67
|
-
"@infinitetoken/jest-config": "^0.
|
|
73
|
+
"@infinitetoken/eslint-config": "^0.2.0",
|
|
74
|
+
"@infinitetoken/jest-config": "^0.3.0",
|
|
68
75
|
"@infinitetoken/tsconfig": "^0.4.1",
|
|
69
76
|
"@rific/auto-paper": "^0.10.3",
|
|
70
77
|
"@rific/feedback-press": "^0.10.8",
|
|
71
|
-
"@rific/updater": "^0.
|
|
78
|
+
"@rific/updater": "^0.6.0",
|
|
72
79
|
"@shopify/react-native-skia": "^2.6.2",
|
|
73
|
-
"@tastic/core": "^0.
|
|
80
|
+
"@tastic/core": "^0.3.0",
|
|
74
81
|
"@testing-library/dom": "^10.4.1",
|
|
75
82
|
"@testing-library/react": "^16.3.2",
|
|
76
83
|
"@types/jest": "^30.0.0",
|
|
@@ -94,7 +101,7 @@
|
|
|
94
101
|
"@rific/feedback-press": ">=0.10.0",
|
|
95
102
|
"@rific/updater": ">=0.4.0",
|
|
96
103
|
"@shopify/react-native-skia": ">=1.5.0",
|
|
97
|
-
"@tastic/core": ">=0.
|
|
104
|
+
"@tastic/core": ">=0.3.0",
|
|
98
105
|
"react": ">=19.0.0",
|
|
99
106
|
"react-native": ">=0.76.0",
|
|
100
107
|
"react-native-paper": ">=5.0.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AutoAppearancePicker, Dialog, useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { Button, SoundContext, TouchableRipple, useHapticSettings, useSoundSettings, useVibration } from '@rific/feedback-press'
|
|
3
3
|
import { useUpdater } from '@rific/updater'
|
|
4
|
-
import { useIsTouchPrimaryDevice } from '@tastic/core'
|
|
4
|
+
import { useIsTouchPrimaryDevice, useRotation } from '@tastic/core'
|
|
5
5
|
import { ReactNode, useContext, useState } from 'react'
|
|
6
6
|
import { Platform, ScrollView, StyleSheet, View } from 'react-native'
|
|
7
7
|
import { Icon, Portal, Text } from 'react-native-paper'
|
|
@@ -26,10 +26,11 @@ function SettingIcon({ source, color, containerColor }: SettingIconProps) {
|
|
|
26
26
|
export interface BaseSettingsDialogProps {
|
|
27
27
|
visible: boolean
|
|
28
28
|
onDismiss: () => void
|
|
29
|
-
//
|
|
30
|
-
// app-wide modal with no per-player zone to match (unlike an in-game round-over
|
|
31
|
-
// just rotates its own content in place
|
|
32
|
-
//
|
|
29
|
+
// Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — this is
|
|
30
|
+
// a centered, app-wide modal with no per-player zone to match (unlike an in-game round-over
|
|
31
|
+
// dialog), so it just rotates its own content in place. Defaults to a live ambient read via
|
|
32
|
+
// useRotation() when omitted; pass this only when a caller's own reading needs to differ from the
|
|
33
|
+
// ambient one (rare — most callers should simply omit it).
|
|
33
34
|
rotation?: number
|
|
34
35
|
// Each app's own OTA version (e.g. release.otaVersion) — not something this package can read
|
|
35
36
|
// itself, since every consuming app tracks its own release.ts independently. Accepts a number too
|
|
@@ -76,8 +77,10 @@ export interface BaseSettingsDialogProps {
|
|
|
76
77
|
// is the seam for whatever isn't shared: a game with its own board/difficulty settings (or a stats
|
|
77
78
|
// backup flow, or anything else genuinely specific to it) renders those below Appearance, not by
|
|
78
79
|
// forking this component.
|
|
79
|
-
export function BaseSettingsDialog({ visible, onDismiss, rotation
|
|
80
|
+
export function BaseSettingsDialog({ visible, onDismiss, rotation: rotationOverride, version, lockOrientation, onLockOrientationChange, deferBottomEdgeGestures, onDeferBottomEdgeGestures, hideSound = false, hideHaptics = false, hideAppearance = false, hideUpdateCheck = false, onUpdateError, children }: BaseSettingsDialogProps) {
|
|
80
81
|
const { dark, colors } = useAutoPaperTheme()
|
|
82
|
+
const ambientRotation = useRotation()
|
|
83
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
81
84
|
// Also gates the Lock Orientation row below (alongside showLockOrientation itself) — true
|
|
82
85
|
// unconditionally on native (see the hook's own doc), so this only actually excludes a
|
|
83
86
|
// desktop/laptop browser, where a mouse-driven window has no physical orientation to lock
|
package/src/BaseStatsScreen.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
2
|
import { Button, IconButton } from '@rific/feedback-press'
|
|
3
|
+
import { useRotation } from '@tastic/core'
|
|
3
4
|
import { ReactNode, useState } from 'react'
|
|
4
5
|
import { ScrollView, StyleSheet, View } from 'react-native'
|
|
5
6
|
import { Icon, Portal, Text } from 'react-native-paper'
|
|
@@ -10,12 +11,12 @@ import { Icon, Portal, Text } from 'react-native-paper'
|
|
|
10
11
|
type MD3TextVariant = 'displayLarge' | 'displayMedium' | 'displaySmall' | 'headlineLarge' | 'headlineMedium' | 'headlineSmall' | 'titleLarge' | 'titleMedium' | 'titleSmall' | 'labelLarge' | 'labelMedium' | 'labelSmall' | 'bodyLarge' | 'bodyMedium' | 'bodySmall'
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
14
|
+
// Explicit override for the live physical-hold rotation (see @tastic/core's useRotation) — only
|
|
15
|
+
// affects this component's own Portal-rendered reset-confirm overlay, which (like any Portal
|
|
16
|
+
// content) is unaffected by whichever screen's own FakeLandscapeView wraps the trigger and so has
|
|
17
|
+
// to rotate itself. Defaults to a live ambient read via useRotation() when omitted — note this
|
|
18
|
+
// does NOT make the header/scrollable content below rotate; the caller's own screen still needs
|
|
19
|
+
// its own FakeLandscapeView wrap for that (this component has no transform on its own main body).
|
|
19
20
|
rotation?: number
|
|
20
21
|
title?: string
|
|
21
22
|
// 'headlineSmall' (this screen's own default, below) — was originally 'displaySmall' (the full
|
|
@@ -67,8 +68,10 @@ interface Props {
|
|
|
67
68
|
// black/white convention. `children` is the seam for whatever isn't shared: this component has no
|
|
68
69
|
// opinion on what a "stat" is or how achievements are catalogued — see StatRow/StatSection/
|
|
69
70
|
// AchievementRow for the smaller presentational pieces built to go inside it.
|
|
70
|
-
export function BaseStatsScreen({ rotation
|
|
71
|
+
export function BaseStatsScreen({ rotation: rotationOverride, title = 'Achievements', titleVariant = 'headlineSmall', onBack, insets, onReset, resetLabel = 'Reset All Stats', resetConfirmTitle = 'Reset Everything?', resetConfirmBody = 'This permanently erases all stats and achievements. This cannot be undone.', fg: fgOverride, bg: bgOverride, cardBg: cardBgOverride, accentColor: accentColorOverride, children }: Props) {
|
|
71
72
|
const { dark, colors } = useAutoPaperTheme()
|
|
73
|
+
const ambientRotation = useRotation()
|
|
74
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
72
75
|
const [confirmResetVisible, setConfirmResetVisible] = useState(false)
|
|
73
76
|
const showReset = !!onReset
|
|
74
77
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useAutoPaperTheme } from '@rific/auto-paper'
|
|
2
|
+
import { Button } from '@rific/feedback-press'
|
|
3
|
+
import { StyleSheet, View } from 'react-native'
|
|
4
|
+
import { Icon, Portal, Text } from 'react-native-paper'
|
|
5
|
+
|
|
6
|
+
export interface ConfirmDialogProps {
|
|
7
|
+
visible: boolean
|
|
8
|
+
title: string
|
|
9
|
+
message: string
|
|
10
|
+
confirmLabel: string
|
|
11
|
+
cancelLabel: string
|
|
12
|
+
onConfirm: () => void
|
|
13
|
+
onCancel: () => void
|
|
14
|
+
// Live physical-hold rotation (see @tastic/split-screen's getViewRotation) - same convention as
|
|
15
|
+
// BaseSettingsDialog's own rotation prop; defaults to 0 for a caller with no per-player zone to
|
|
16
|
+
// match (a portrait-only or single-player game has nothing to stay consistent with).
|
|
17
|
+
rotation?: number
|
|
18
|
+
// Any react-native-paper Icon `source` name, shown above the title - defaults to a plain
|
|
19
|
+
// question mark, since this component has no way to know why a caller is asking.
|
|
20
|
+
icon?: string
|
|
21
|
+
// Whether the confirm button reads as destructive (the fleet's shared `danger` semantic role)
|
|
22
|
+
// instead of the default primary-colored affirmative action.
|
|
23
|
+
destructive?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A themed replacement for the native Alert.alert/window.confirm two-button confirm pattern - the
|
|
27
|
+
// same "retro card overlay" look BaseSettingsDialog's own update-check info messages already use
|
|
28
|
+
// (see its onInfo/infoMessage), generalized here into its own reusable, exported component for any
|
|
29
|
+
// caller that needs a real yes/no decision instead of a single OK dismissal. A solid backdrop +
|
|
30
|
+
// centered card, not react-native-paper's own Dialog (which BaseSettingsDialog uses instead) -
|
|
31
|
+
// this renders identically on every platform, including web, where react-native-web's Alert.alert
|
|
32
|
+
// is a hard no-op (confirmed: its entire implementation is `static alert() {}`) and Dialog's own
|
|
33
|
+
// Portal-based positioning has its own web quirks BaseSettingsDialog already works around.
|
|
34
|
+
export function ConfirmDialog({ visible, title, message, confirmLabel, cancelLabel, onConfirm, onCancel, rotation = 0, icon = 'help-circle-outline', destructive = false }: ConfirmDialogProps) {
|
|
35
|
+
const { dark, colors } = useAutoPaperTheme()
|
|
36
|
+
if (!visible) return null
|
|
37
|
+
|
|
38
|
+
// High-contrast retro look: literal black/white by appearance, not auto-paper's own (slightly
|
|
39
|
+
// tinted) background role - same convention BaseSettingsDialog's own overlay card uses.
|
|
40
|
+
const fg = dark ? '#FFFFFF' : '#000000'
|
|
41
|
+
const cardBg = dark ? '#111111' : '#F2F2F2'
|
|
42
|
+
const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
|
|
43
|
+
const accent = destructive ? colors.danger : colors.primary
|
|
44
|
+
const onAccent = destructive ? colors.onDanger : colors.onPrimary
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Portal>
|
|
48
|
+
<View style={styles.overlay}>
|
|
49
|
+
<View style={[styles.card, { backgroundColor: cardBg, borderColor: cardBorder }, rotation % 360 !== 0 && { transform: [{ rotate: `${rotation}deg` }] }]}>
|
|
50
|
+
<Icon source={icon} size={64} color={accent} />
|
|
51
|
+
<Text variant='headlineLarge' style={[styles.title, { color: accent }]}>
|
|
52
|
+
{title}
|
|
53
|
+
</Text>
|
|
54
|
+
<Text variant='bodyLarge' style={[styles.body, { color: fg }]}>
|
|
55
|
+
{message}
|
|
56
|
+
</Text>
|
|
57
|
+
<View style={styles.actions}>
|
|
58
|
+
<Button mode='outlined' onPress={onCancel} style={styles.actionButton}>
|
|
59
|
+
{cancelLabel}
|
|
60
|
+
</Button>
|
|
61
|
+
<Button mode='contained' onPress={onConfirm} style={styles.actionButton} buttonColor={accent} textColor={onAccent}>
|
|
62
|
+
{confirmLabel}
|
|
63
|
+
</Button>
|
|
64
|
+
</View>
|
|
65
|
+
</View>
|
|
66
|
+
</View>
|
|
67
|
+
</Portal>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = StyleSheet.create({
|
|
72
|
+
actionButton: {
|
|
73
|
+
flex: 1
|
|
74
|
+
},
|
|
75
|
+
actions: {
|
|
76
|
+
flexDirection: 'row',
|
|
77
|
+
gap: 12,
|
|
78
|
+
width: '100%'
|
|
79
|
+
},
|
|
80
|
+
body: {
|
|
81
|
+
textAlign: 'center'
|
|
82
|
+
},
|
|
83
|
+
card: {
|
|
84
|
+
alignItems: 'center',
|
|
85
|
+
borderRadius: 20,
|
|
86
|
+
borderWidth: 1,
|
|
87
|
+
gap: 16,
|
|
88
|
+
maxWidth: 360,
|
|
89
|
+
padding: 32
|
|
90
|
+
},
|
|
91
|
+
overlay: {
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
backgroundColor: 'rgba(0,0,0,0.72)',
|
|
94
|
+
bottom: 0,
|
|
95
|
+
justifyContent: 'center',
|
|
96
|
+
left: 0,
|
|
97
|
+
position: 'absolute',
|
|
98
|
+
right: 0,
|
|
99
|
+
top: 0
|
|
100
|
+
},
|
|
101
|
+
title: {
|
|
102
|
+
fontWeight: 'bold'
|
|
103
|
+
}
|
|
104
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useIsTouchPrimaryDevice } from '@tastic/core'
|
|
2
|
+
import { Platform } from 'react-native'
|
|
3
|
+
|
|
4
|
+
import { MenuOption, SectionedDropdown } from './SectionedDropdown'
|
|
5
|
+
import { PopoverHost } from './usePopoverHost'
|
|
6
|
+
|
|
7
|
+
export interface ControlSchemePickerProps<T extends string | number> {
|
|
8
|
+
id: string
|
|
9
|
+
host: PopoverHost
|
|
10
|
+
value: T
|
|
11
|
+
onChange: (value: T) => void
|
|
12
|
+
options: MenuOption<T>[]
|
|
13
|
+
// Another seat's currently-selected scheme, if any — passed straight through as the underlying
|
|
14
|
+
// section's own takenValue (see SectionedDropdown's own doc: stays in the list, shown disabled,
|
|
15
|
+
// rather than filtered out). Two local seats sharing one physical keyboard (and, depending on your
|
|
16
|
+
// own option set, one mouse) can't both be assigned the same scheme at once; this is what a caller
|
|
17
|
+
// wires up to prevent that.
|
|
18
|
+
takenValue?: T
|
|
19
|
+
accentColor: string
|
|
20
|
+
mutedColor: string
|
|
21
|
+
onAccentColor?: string
|
|
22
|
+
dark: boolean
|
|
23
|
+
align?: 'left' | 'right' | 'center'
|
|
24
|
+
labelFontFamily?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A single-select SectionedDropdown, opinionated for exactly one job: letting a local seat pick
|
|
28
|
+
// which physical input drives it in a couch-multiplayer game. Fixes the trigger icon and
|
|
29
|
+
// accessibility label so every game's control-scheme picker looks and reads the same, and — the
|
|
30
|
+
// actual reason this exists as its own component rather than a usage snippet in each app's own
|
|
31
|
+
// docs — bakes in the "does this even mean anything right now" gate every consumer was independently
|
|
32
|
+
// re-deriving: a physical keyboard scheme has no meaning on a touch-primary device (there's nothing
|
|
33
|
+
// to pick a *keyboard* layout for), and this repo's own components target React Native Web, where
|
|
34
|
+
// `Platform.OS !== 'web'` means native/touch already, so keying off `useIsTouchPrimaryDevice` alone
|
|
35
|
+
// isn't enough on its own — a native touch device still needs the `Platform.OS` check to be excluded
|
|
36
|
+
// the same way a desktop browser needs the touch check. Renders nothing at all when either
|
|
37
|
+
// condition fails, rather than a disabled/empty picker, so a caller can mount this unconditionally
|
|
38
|
+
// inside its own per-seat layout (a `View` row alongside a color picker, say) without its own
|
|
39
|
+
// `Platform`/`useIsTouchPrimaryDevice` branch — an always-rendered-but-sometimes-null child is a
|
|
40
|
+
// no-op in a flex layout, so the surrounding row collapses to just its other children automatically.
|
|
41
|
+
//
|
|
42
|
+
// Deliberately generic over `T` (matching SectionedDropdown's own `MenuOption<T>`) rather than
|
|
43
|
+
// hardcoding a fixed scheme union: this repo has no opinion on which schemes exist — the raw
|
|
44
|
+
// physical-key layouts (wasd/arrows/ijkl/numpad) live in `@tastic/input`, and any extra values with
|
|
45
|
+
// no keys of their own (a "keep using pointer input" option, say) are purely a per-app label — so a
|
|
46
|
+
// caller always supplies its own `options` (and typically its own `T`) rather than this component
|
|
47
|
+
// importing `@tastic/input` and choosing a set for every consumer.
|
|
48
|
+
export function ControlSchemePicker<T extends string | number>({ id, host, value, onChange, options, takenValue, accentColor, mutedColor, onAccentColor, dark, align, labelFontFamily }: ControlSchemePickerProps<T>) {
|
|
49
|
+
const isTouchPrimary = useIsTouchPrimaryDevice()
|
|
50
|
+
if (Platform.OS !== 'web' || isTouchPrimary) return null
|
|
51
|
+
|
|
52
|
+
return <SectionedDropdown id={id} host={host} icon='keyboard-outline' accessibilityLabel='Control scheme' sections={[{ kind: 'single', id: 'controls', options, value, onChange, takenValue }]} accentColor={accentColor} mutedColor={mutedColor} onAccentColor={onAccentColor} dark={dark} align={align} labelFontFamily={labelFontFamily} />
|
|
53
|
+
}
|
|
@@ -12,6 +12,14 @@ interface Props {
|
|
|
12
12
|
// @tastic/split-screen's rotateInsets) — this component just adds the fixed 8px margin on top,
|
|
13
13
|
// it doesn't know anything about physical device orientation itself.
|
|
14
14
|
insets: { top: number; left: number; right: number }
|
|
15
|
+
// Overrides the top-left button's icon (a react-native-paper/MaterialCommunityIcons name) and its
|
|
16
|
+
// accessibility label — default 'arrow-left'/'Back'. Not every screen's top-left control is
|
|
17
|
+
// actually "undo the last navigation": a flat menu/detail screen with nothing deeper to pop back
|
|
18
|
+
// through often wants a literal home/menu affordance driving an absolute navigation instead (see
|
|
19
|
+
// e.g. Solitaire's GameChrome, whose onBack navigates straight to its root route). Pass both
|
|
20
|
+
// together — an icon without a matching label (or vice versa) misleads screen readers/tooltips.
|
|
21
|
+
backIcon?: string
|
|
22
|
+
backLabel?: string
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
// Back (top-left) + Settings (top-right) icon buttons, fixed at the screen's rotated corners — the
|
|
@@ -20,10 +28,10 @@ interface Props {
|
|
|
20
28
|
// right answer for a screen where the top corners themselves fall *inside* one player's own rotated
|
|
21
29
|
// zone (two-player face-to-face) — see SharedActionBand for that case instead, which callers select
|
|
22
30
|
// between based on their own game-mode/orientation state (this component has no opinion on which).
|
|
23
|
-
export function CornerActionButtons({ onBack, onSettings, fg, insets }: Props) {
|
|
31
|
+
export function CornerActionButtons({ onBack, onSettings, fg, insets, backIcon = 'arrow-left', backLabel = 'Back' }: Props) {
|
|
24
32
|
return (
|
|
25
33
|
<>
|
|
26
|
-
<IconButton icon=
|
|
34
|
+
<IconButton icon={backIcon} iconColor={fg} size={24} style={[styles.back, { top: 8 + insets.top, left: 8 + insets.left }]} onPress={onBack} accessibilityLabel={backLabel} />
|
|
27
35
|
<IconButton icon='cog' iconColor={fg} size={24} style={[styles.topRight, { top: 8 + insets.top, right: 8 + insets.right }]} onPress={onSettings} accessibilityLabel='Settings' />
|
|
28
36
|
</>
|
|
29
37
|
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { StyleSheet, View } from 'react-native'
|
|
2
|
+
import { Icon } from 'react-native-paper'
|
|
3
|
+
|
|
4
|
+
// Mirrors @tastic/split-screen's own type of the same name, duplicated here rather than imported —
|
|
5
|
+
// this package has never taken a dependency on @tastic/split-screen; every orientation/rotation-
|
|
6
|
+
// derived value it touches (ConfirmDialog's and BaseSettingsDialog's own `rotation` props, this
|
|
7
|
+
// component's orientationMode/p1OnRight below) is always a plain value the caller already resolved
|
|
8
|
+
// via that package's own hook, never something this package reads itself.
|
|
9
|
+
export type OrientationMode = 'faceToFace' | 'sideBySide'
|
|
10
|
+
|
|
11
|
+
export interface CornerStatusBadgeSeat {
|
|
12
|
+
// Any react-native-paper Icon `source` name. Omit to still render the badge itself, empty — a
|
|
13
|
+
// seat with nothing to show right now is different from no seat at all (see `p1`/`p2` on
|
|
14
|
+
// CornerStatusBadgesProps below), and the empty badge is itself the answer ("nothing"), not a
|
|
15
|
+
// reason to hide the corner.
|
|
16
|
+
icon?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CornerStatusBadgesProps {
|
|
20
|
+
orientationMode: OrientationMode
|
|
21
|
+
// Same "is this seat on the right" convention as @tastic/split-screen's own p1OnRight — only
|
|
22
|
+
// meaningful when orientationMode is 'sideBySide'; ignored in 'faceToFace', where seat 1 always
|
|
23
|
+
// owns the bottom half of a shared screen and seat 2 the top half, regardless of physical rotation.
|
|
24
|
+
p1OnRight: boolean
|
|
25
|
+
// Independently optional — omit a seat entirely to skip rendering its badge at all (e.g. a solo
|
|
26
|
+
// mode with no second seat to show anything for). Pass an object (even `{}`) to render that
|
|
27
|
+
// seat's badge with nothing in it.
|
|
28
|
+
p1?: CornerStatusBadgeSeat
|
|
29
|
+
p2?: CornerStatusBadgeSeat
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const BADGE_SIZE = 30
|
|
33
|
+
|
|
34
|
+
// Deliberately understated (small, low-opacity, no per-seat color) — a quiet corner landmark, not
|
|
35
|
+
// something that should compete for attention with whatever's on screen underneath it.
|
|
36
|
+
function StatusBadge({ icon }: { icon?: string }) {
|
|
37
|
+
return <View style={styles.badge}>{icon && <Icon source={icon} size={16} color='rgba(255,255,255,0.85)' />}</View>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Generic per-seat corner status badge: at most one caller-supplied icon per seat (or none), each
|
|
41
|
+
// pinned to that seat's own screen corner. Has zero opinion on what the icon means — originally
|
|
42
|
+
// extracted from LightCycles' PowerupHud.tsx and Snake's SnakePowerupHud.tsx, which both used this
|
|
43
|
+
// exact positioning and styling to reveal a held power-up, but this component only ever sees a
|
|
44
|
+
// resolved icon name (or undefined) per seat, never the domain value behind it, so it's equally
|
|
45
|
+
// suited to any other per-seat status a caller wants glanceable in a screen corner (connection
|
|
46
|
+
// state, a turn-order marker, anything that's a single icon or nothing). Corner picked per seat
|
|
47
|
+
// from orientationMode/p1OnRight — the same "is this seat on the right" convention
|
|
48
|
+
// @tastic/split-screen's own consumers already use for zone/rotation decisions — so each badge
|
|
49
|
+
// always sits in that seat's own zone rather than a side fixed regardless of which way the device
|
|
50
|
+
// is being held. In 'faceToFace', column doesn't matter (each seat's zone spans the full width):
|
|
51
|
+
// seat 1 stays bottom, seat 2 stays top, regardless of p1OnRight.
|
|
52
|
+
export function CornerStatusBadges({ orientationMode, p1OnRight, p1, p2 }: CornerStatusBadgesProps) {
|
|
53
|
+
const p1OnRightSide = orientationMode === 'sideBySide' && p1OnRight
|
|
54
|
+
const p2OnLeftSide = orientationMode === 'sideBySide' && !p1OnRight
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{p1 && (
|
|
58
|
+
<View pointerEvents='none' style={p1OnRightSide ? styles.bottomRight : styles.bottomLeft}>
|
|
59
|
+
<StatusBadge icon={p1.icon} />
|
|
60
|
+
</View>
|
|
61
|
+
)}
|
|
62
|
+
{p2 && (
|
|
63
|
+
<View pointerEvents='none' style={p2OnLeftSide ? styles.topLeft : styles.topRight}>
|
|
64
|
+
<StatusBadge icon={p2.icon} />
|
|
65
|
+
</View>
|
|
66
|
+
)}
|
|
67
|
+
</>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const styles = StyleSheet.create({
|
|
72
|
+
badge: {
|
|
73
|
+
alignItems: 'center',
|
|
74
|
+
backgroundColor: 'rgba(0,0,0,0.28)',
|
|
75
|
+
borderColor: 'rgba(255,255,255,0.18)',
|
|
76
|
+
borderRadius: BADGE_SIZE / 2,
|
|
77
|
+
borderWidth: 1,
|
|
78
|
+
height: BADGE_SIZE,
|
|
79
|
+
justifyContent: 'center',
|
|
80
|
+
opacity: 0.7,
|
|
81
|
+
width: BADGE_SIZE
|
|
82
|
+
},
|
|
83
|
+
bottomLeft: { bottom: 12, left: 12, position: 'absolute' },
|
|
84
|
+
bottomRight: { bottom: 12, position: 'absolute', right: 12 },
|
|
85
|
+
topLeft: { left: 12, position: 'absolute', top: 12 },
|
|
86
|
+
topRight: { position: 'absolute', right: 12, top: 12 }
|
|
87
|
+
})
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { defaultColors, getContrastColor, SeedColor } from '@rific/auto-paper'
|
|
2
2
|
import { TouchableRipple } from '@rific/feedback-press'
|
|
3
|
-
import { clamp } from '@tastic/core'
|
|
3
|
+
import { clamp, useRotation } from '@tastic/core'
|
|
4
4
|
import { ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
5
5
|
import { Icon, Text } from 'react-native-paper'
|
|
6
6
|
|
|
7
7
|
import { MONO_FONT } from './fonts'
|
|
8
8
|
import { PopoverBody } from './PopoverBody'
|
|
9
|
-
import { useAutoAlign } from './useAutoAlign'
|
|
9
|
+
import { PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
10
10
|
import { PopoverHost } from './usePopoverHost'
|
|
11
11
|
|
|
12
12
|
// Presence check, not a full-string "is this exactly one emoji" validation — this only decides a
|
|
@@ -62,6 +62,10 @@ interface Props {
|
|
|
62
62
|
// Manual override — omit to let the popover measure its own trigger and pick whichever alignment
|
|
63
63
|
// keeps it from overflowing the screen edge (see useAutoAlign).
|
|
64
64
|
align?: 'left' | 'right' | 'center'
|
|
65
|
+
// Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or
|
|
66
|
+
// equivalent) ancestor is currently applying — see SectionedDropdown's identical prop for the
|
|
67
|
+
// full reasoning. Defaults to a live ambient read via @tastic/core's useRotation() when omitted.
|
|
68
|
+
rotation?: PopoverRotation
|
|
65
69
|
// Trigger glyph — defaults to a plain palette. Pass a distinct icon per slot (e.g. a face for a
|
|
66
70
|
// human, a robot for CPU) to convey identity through the icon itself. Overridden by `tag` below
|
|
67
71
|
// when present.
|
|
@@ -89,7 +93,9 @@ interface Props {
|
|
|
89
93
|
// Renders inline rather than as a full-screen modal, scoped to its own panel — a modal color
|
|
90
94
|
// picker would block the whole screen for one player while another can't touch their own panel at
|
|
91
95
|
// the same time, which defeats the point of a split-screen lobby.
|
|
92
|
-
export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
|
|
96
|
+
export function InlineColorPicker({ id, host, value, onChange, previewValue, swatches = defaultColors, takenValue, allowSwapTaken, dark, align: alignOverride, rotation: rotationOverride, icon = 'palette', tag, labelFontFamily = MONO_FONT, autoDismiss = true, columns, size = DEFAULT_SIZE }: Props) {
|
|
97
|
+
const ambientRotation = useRotation()
|
|
98
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
93
99
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
94
100
|
const { width: windowWidth } = useWindowDimensions()
|
|
95
101
|
const autoColumns = clamp(Math.floor((windowWidth - 2 * SCREEN_MARGIN + SWATCHES_GAP) / (SWATCH_SIZE + SWATCHES_GAP)), MIN_COLUMNS, MAX_COLUMNS)
|
|
@@ -99,7 +105,7 @@ export function InlineColorPicker({ id, host, value, onChange, previewValue, swa
|
|
|
99
105
|
const swatchesHeight = SWATCHES_BORDER_WIDTH * 2 + SWATCHES_PADDING * 2 + SWATCH_SIZE * swatchRows + SWATCHES_GAP * (swatchRows - 1)
|
|
100
106
|
|
|
101
107
|
const open = host.openId === id
|
|
102
|
-
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight)
|
|
108
|
+
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, swatchesWidth, swatchesHeight, rotation)
|
|
103
109
|
const align = alignOverride ?? autoAlign
|
|
104
110
|
const displayFor = previewValue ?? ((hex: string) => hex)
|
|
105
111
|
const displayValue = displayFor(value)
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { getBlendedColor, getColorRoles } from '@rific/auto-paper'
|
|
2
2
|
import { IconButton, TouchableRipple } from '@rific/feedback-press'
|
|
3
|
+
import { useRotation } from '@tastic/core'
|
|
3
4
|
import { ScrollView, StyleSheet, TextStyle, View } from 'react-native'
|
|
4
5
|
import { Icon, Text } from 'react-native-paper'
|
|
5
6
|
|
|
6
7
|
import { MONO_FONT } from './fonts'
|
|
7
8
|
import { PopoverBody } from './PopoverBody'
|
|
8
9
|
import TriggerGaugeHost from './TriggerGaugeHost'
|
|
9
|
-
import { useAutoAlign } from './useAutoAlign'
|
|
10
|
+
import { PopoverRotation, useAutoAlign } from './useAutoAlign'
|
|
10
11
|
import { PopoverHost } from './usePopoverHost'
|
|
11
12
|
|
|
12
13
|
const MENU_BORDER_WIDTH = 1
|
|
@@ -99,6 +100,17 @@ interface Props {
|
|
|
99
100
|
// keeps it from overflowing the screen edge (see useAutoAlign). Only pass this to force a specific
|
|
100
101
|
// side regardless of where the trigger actually sits.
|
|
101
102
|
align?: 'left' | 'right' | 'center'
|
|
103
|
+
// Explicit override for the rotation a @tastic/split-screen-style FakeLandscapeView (or any other
|
|
104
|
+
// ancestor rotated the same way — a bare CSS `transform: rotate()`, not an actual OS-level
|
|
105
|
+
// orientation change) is currently applying. Defaults to a live ambient read via @tastic/core's
|
|
106
|
+
// useRotation() when omitted, so most callers never need to pass this at all. Pass it explicitly
|
|
107
|
+
// only when a caller's own reading needs to differ from the ambient one (e.g. a fading dual-zone
|
|
108
|
+
// layout whose panel content deliberately lags the live reading behind a transition). Correctness
|
|
109
|
+
// matters here regardless of source: measureInWindow reports the trigger's PRE-rotation layout
|
|
110
|
+
// position (see useAutoAlign's own doc comment for why, and why it can't recover this any other
|
|
111
|
+
// way), so a wrong rotation value picks an alignment for where the trigger *used to be* before
|
|
112
|
+
// rotating, clipping off the edge it's actually now near instead of the one it was near.
|
|
113
|
+
rotation?: PopoverRotation
|
|
102
114
|
// Whether picking a value in a *single-select* section closes the popover. Defaults to true
|
|
103
115
|
// (the old, pre-press-away default: pick one thing, you're done). Multi-select rows never
|
|
104
116
|
// auto-close regardless of this — see MultiSelectSection's own comment. Worth setting false for a
|
|
@@ -116,7 +128,9 @@ interface Props {
|
|
|
116
128
|
// powerups are enabled, say) is just this with one 'multi' section (optionally with allClear); a
|
|
117
129
|
// combined menu (e.g. spawn frequency + which types, in one menu) is just two sections in the same
|
|
118
130
|
// array.
|
|
119
|
-
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
131
|
+
export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections, accentColor, mutedColor, onAccentColor, dark, align: alignOverride, rotation: rotationOverride, autoDismiss = true, labelFontFamily = MONO_FONT, allClearLabels = { all: 'All', clear: 'Clear' } }: Props) {
|
|
132
|
+
const ambientRotation = useRotation()
|
|
133
|
+
const rotation = rotationOverride ?? ambientRotation
|
|
120
134
|
const menuBg = dark ? '#000000' : '#FFFFFF'
|
|
121
135
|
// mutedColor is translucent (fine for the icon/text tints below, each a single paint), but the
|
|
122
136
|
// caret is deliberately drawn overlapping the box's own top border (PopoverBody's CARET_DIP,
|
|
@@ -142,7 +156,7 @@ export function SectionedDropdown({ id, host, icon, accessibilityLabel, sections
|
|
|
142
156
|
const divider = sectionIndex > 0 ? MENU_DIVIDER_HEIGHT : 0
|
|
143
157
|
return sum + rows + footer + divider
|
|
144
158
|
}, 0)
|
|
145
|
-
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight)
|
|
159
|
+
const { align: autoAlign, maxHeight, measured, triggerRef, verticalAlign } = useAutoAlign(open, MENU_MAX_WIDTH, estimatedHeight, rotation)
|
|
146
160
|
const align = alignOverride ?? autoAlign
|
|
147
161
|
|
|
148
162
|
// Trigger reflects the selected option's own icon only when there's exactly one section and it's
|
package/src/SkiaGate.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
export interface SkiaGateProps<P extends object> {
|
|
5
|
+
// A dynamic import, e.g. `() => import('./Board')` — hoist to module scope at the call site
|
|
6
|
+
// (never an inline closure written directly in JSX) so its identity stays stable across
|
|
7
|
+
// renders; it's this component's own useEffect dependency below.
|
|
8
|
+
getComponent: () => Promise<{ default: ComponentType<P> }>
|
|
9
|
+
componentProps: P
|
|
10
|
+
// Rendered while getComponent()'s promise is still pending. Defaults to null.
|
|
11
|
+
fallback?: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Native Skia renders through JSI — no WASM/CanvasKit fetch to wait on — so this file just
|
|
15
|
+
// lazily mounts whatever getComponent() resolves to, once it resolves. The .web.tsx sibling
|
|
16
|
+
// (picked by Metro/webpack's own platform-extension resolution for web bundles) is a different
|
|
17
|
+
// implementation entirely: a direct re-export of @shopify/react-native-skia's own WithSkiaWeb,
|
|
18
|
+
// which gates the same mount behind LoadSkiaWeb() first — CanvasKit's WASM build has to actually
|
|
19
|
+
// finish loading before anything that imports '@shopify/react-native-skia' can evaluate its own
|
|
20
|
+
// top-level `Skia` binding without crashing (see loadSkiaWeb.web.ts's own comment).
|
|
21
|
+
//
|
|
22
|
+
// No React.lazy()/Suspense here, deliberately: lazy() can't be called inline during render
|
|
23
|
+
// (react-hooks/static-components — a getComponent prop that isn't referentially stable would
|
|
24
|
+
// give a new component identity, and therefore an unwanted remount, on every render that passes
|
|
25
|
+
// a fresh one) and can't be cached in a ref either (react-hooks/refs forbids reading or writing
|
|
26
|
+
// ref.current during render, only inside an effect). Awaiting the import directly and setting
|
|
27
|
+
// state from the resolved module sidesteps both — setState only ever runs from the promise
|
|
28
|
+
// callback, never synchronously in render or in the effect body itself.
|
|
29
|
+
export function SkiaGate<P extends object>({ getComponent, componentProps, fallback = null }: SkiaGateProps<P>) {
|
|
30
|
+
const [Component, setComponent] = useState<ComponentType<P> | null>(null)
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let cancelled = false
|
|
34
|
+
getComponent().then((mod) => {
|
|
35
|
+
if (!cancelled) setComponent(() => mod.default)
|
|
36
|
+
})
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true
|
|
39
|
+
}
|
|
40
|
+
}, [getComponent])
|
|
41
|
+
|
|
42
|
+
if (!Component) return fallback
|
|
43
|
+
|
|
44
|
+
return <Component {...componentProps} />
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Web needs CanvasKit's WASM loaded before any Skia <Canvas> evaluates — see loadSkiaWeb.web.ts's
|
|
2
|
+
// own comment for why a plain useEffect-based loader can't fix this (it's a module-evaluation-
|
|
3
|
+
// order problem, not a component-lifecycle one). Only this file (picked by Metro/webpack's
|
|
4
|
+
// platform-extension resolution for web bundles) may import from
|
|
5
|
+
// '@shopify/react-native-skia/src/web' — a static import of that subpath from a native-reachable
|
|
6
|
+
// file pulls canvaskit-wasm's `require('fs')` into the native bundle and breaks iOS/Android
|
|
7
|
+
// builds; this file vs. SkiaGate.tsx is what actually keeps it out, the same split
|
|
8
|
+
// loadSkiaWeb.web.ts/loadSkiaWeb.ts already use.
|
|
9
|
+
export { WithSkiaWeb as SkiaGate } from '@shopify/react-native-skia/src/web'
|
package/src/TriggerGaugeHost.tsx
CHANGED
|
@@ -1,26 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
import { loadSkiaWeb } from './loadSkiaWeb'
|
|
1
|
+
import { SkiaGate } from './SkiaGate'
|
|
4
2
|
import { TriggerGaugeProps } from './TriggerGauge'
|
|
5
3
|
|
|
6
4
|
// TriggerGauge.tsx's `Skia` import binds to global.CanvasKit at module-evaluation time, which on web
|
|
7
|
-
// only exists once
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
// consumer's trigger icon still shows immediately and the gauge ring just pops in a beat later.
|
|
18
|
-
const LazyTriggerGauge = lazy(() => loadSkiaWeb().then(() => import('./TriggerGauge').then((m) => ({ default: m.TriggerGauge }))))
|
|
5
|
+
// only exists once CanvasKit's WASM fetch resolves — a plain `import { TriggerGauge } from
|
|
6
|
+
// './TriggerGauge'` at the top of this file would already have evaluated that binding, against a
|
|
7
|
+
// not-yet-loaded, undefined CanvasKit, before anything in a consuming app renders at all, since
|
|
8
|
+
// bundlers evaluate the whole static import graph up front. This file therefore never imports
|
|
9
|
+
// TriggerGauge itself except as a type (erased at compile time, carrying no runtime module
|
|
10
|
+
// reference) — getTriggerGauge below, hoisted to module scope so its identity stays stable across
|
|
11
|
+
// renders (see SkiaGate's own comment on why that matters), is what actually defers the real import
|
|
12
|
+
// to runtime. No explicit loadSkiaWeb() call here on web: SkiaGate.web.tsx (WithSkiaWeb) already
|
|
13
|
+
// gates its own mount behind CanvasKit being ready, and on native there's nothing to wait on at all.
|
|
14
|
+
const getTriggerGauge = () => import('./TriggerGauge').then((mod) => ({ default: mod.TriggerGauge }))
|
|
19
15
|
|
|
20
16
|
export default function TriggerGaugeHost(props: TriggerGaugeProps) {
|
|
21
|
-
return
|
|
22
|
-
<Suspense fallback={null}>
|
|
23
|
-
<LazyTriggerGauge {...props} />
|
|
24
|
-
</Suspense>
|
|
25
|
-
)
|
|
17
|
+
return <SkiaGate getComponent={getTriggerGauge} componentProps={props} />
|
|
26
18
|
}
|