@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/src/index.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  export { AchievementRow, LOCKED_BADGE_COLOR } from './AchievementRow'
2
2
  export { BaseSettingsDialog, type BaseSettingsDialogProps } from './BaseSettingsDialog'
3
3
  export { BaseStatsScreen } from './BaseStatsScreen'
4
+ export { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog'
4
5
  export { ContentGutter } from './ContentGutter'
6
+ export { ControlSchemePicker, type ControlSchemePickerProps } from './ControlSchemePicker'
5
7
  export { CornerActionButtons } from './CornerActionButtons'
8
+ export { CornerStatusBadges, type CornerStatusBadgeSeat, type CornerStatusBadgesProps, type OrientationMode } from './CornerStatusBadges'
6
9
  export { MONO_FONT } from './fonts'
7
10
  export { InlineColorPicker } from './InlineColorPicker'
8
11
  export { getLabeledDropdownContentHeight, LABELED_DROPDOWN_POPOVER_WIDTH, LabeledDropdown, type LabeledDropdownOption } from './LabeledDropdown'
@@ -12,16 +15,17 @@ export { PressAwayOverlay } from './PressAwayOverlay'
12
15
  export { ReadyButton } from './ReadyButton'
13
16
  export { type MenuOption, type MenuSection, type MultiSelectSection, SectionedDropdown, type SingleSelectSection } from './SectionedDropdown'
14
17
  export { SharedActionBand } from './SharedActionBand'
18
+ export { SkiaGate, type SkiaGateProps } from './SkiaGate'
15
19
  export { StatRow } from './StatRow'
16
20
  export { StatSection } from './StatSection'
17
21
  // TriggerGauge itself (the raw Skia-based component) is deliberately NOT exported here, even
18
- // though a native-only consumer could safely skip TriggerGaugeHost's Suspense/lazy-load
19
- // indirection. A value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import
20
- // into a chunk that this entry point imports eagerly at the top level — since that chunk is then
21
- // shared with TriggerGaugeHost's lazy dynamic import too, it defeats TriggerGaugeHost's own
22
- // deferred loading for every consumer, on every platform, not just the ones reaching for the raw
23
- // component. TriggerGaugeHost is the only supported way to render this.
22
+ // though a native-only consumer could safely skip TriggerGaugeHost's SkiaGate indirection. A
23
+ // value re-export forces the bundler to fold TriggerGauge.tsx's own `Skia` import into a chunk
24
+ // that this entry point imports eagerly at the top level — since that chunk is then shared with
25
+ // TriggerGaugeHost's own dynamic import too, it defeats TriggerGaugeHost's deferred loading for
26
+ // every consumer, on every platform, not just the ones reaching for the raw component.
27
+ // TriggerGaugeHost is the only supported way to render this.
24
28
  export type { TriggerGaugeProps } from './TriggerGauge'
25
29
  export { default as TriggerGaugeHost } from './TriggerGaugeHost'
26
- export { type PopoverAlign, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
30
+ export { type PopoverAlign, type PopoverRotation, type PopoverVerticalAlign, useAutoAlign } from './useAutoAlign'
27
31
  export { type PopoverHost, usePopoverHost } from './usePopoverHost'
@@ -1,3 +1,4 @@
1
+ import { type ViewRotation } from '@tastic/core'
1
2
  import { useCallback, useEffect, useRef, useState } from 'react'
2
3
  import { useWindowDimensions, View } from 'react-native'
3
4
 
@@ -7,15 +8,55 @@ const EDGE_MARGIN = 12
7
8
 
8
9
  export type PopoverAlign = 'left' | 'right' | 'center'
9
10
  export type PopoverVerticalAlign = 'below' | 'above'
11
+ // Same set of values as @tastic/core's own ViewRotation — re-exported under this package's existing
12
+ // name rather than switching every call site to import ViewRotation directly, since "popover
13
+ // rotation" reads clearer at these call sites than the more general core name.
14
+ export type PopoverRotation = ViewRotation
10
15
 
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).
16
+ // Reflects a rect from the PRE-rotation layout frame measureInWindow actually reports (see
17
+ // useAutoAlign's own comment below for why that's what it reports, not the post-rotation visual
18
+ // position) into its true, post-rotation position — needed for a trigger sitting inside a
19
+ // @tastic/split-screen-style FakeLandscapeView, or any other ancestor rotated the same way (a bare
20
+ // CSS `transform: rotate()`, not an actual OS-level orientation change).
21
+ //
22
+ // The pivot is always the window's own center, for every rotation amount: FakeLandscapeView (and
23
+ // this package's own doc-recommended equivalent for a caller that rolls its own) always centers its
24
+ // rotated container — width/height swapped first for ±90°, unchanged for 180° — within the original,
25
+ // un-rotated screen bounds before applying the CSS rotation, so the transform's own effective pivot
26
+ // lands at that shared center regardless of which of the three non-zero rotations it is.
27
+ export function rotateRect(x: number, y: number, width: number, height: number, rotation: PopoverRotation, windowWidth: number, windowHeight: number): { x: number; y: number; width: number; height: number } {
28
+ if (rotation === 0) return { x, y, width, height }
29
+ const cx = windowWidth / 2
30
+ const cy = windowHeight / 2
31
+ const dx = x + width / 2 - cx
32
+ const dy = y + height / 2 - cy
33
+ // Clockwise-positive about (cx, cy) — matches CSS's own rotate(deg) sign convention, which is
34
+ // where every rotation value this ever receives ultimately comes from (getViewRotation and
35
+ // FakeLandscapeView both already commit to that same convention).
36
+ const [newDx, newDy, newWidth, newHeight] = rotation === 180 ? [-dx, -dy, width, height] : rotation === 90 ? [-dy, dx, height, width] : [dy, -dx, height, width]
37
+ return { x: cx + newDx - newWidth / 2, y: cy + newDy - newHeight / 2, width: newWidth, height: newHeight }
38
+ }
39
+
40
+ // Measures a trigger's on-screen position (via measureInWindow) and picks whichever alignment keeps
41
+ // a popover of `contentWidth`×`contentHeight` from overflowing a screen edge — horizontally
42
+ // (left/right/center, preferring center) and vertically (below/above, preferring below — the usual
43
+ // case — but flipping to above when the trigger doesn't have enough room underneath it, which is
44
+ // what a short landscape screen with the trigger row near the top actually runs into).
45
+ //
46
+ // measureInWindow resolves the trigger's PRE-transform layout frame — confirmed against a real
47
+ // rotated screen, not just reasoned about: an earlier version of this comment claimed the opposite
48
+ // (that measureInWindow "resolves post-transform"), which is what let a real bug ship unnoticed,
49
+ // since nothing here ever actually exercised a rotated case to catch that claim being wrong (see
50
+ // this file's own test suite, which now does). A plain `onLayout` reports the same pre-transform
51
+ // frame — there is no plain RN layout API that reports a transformed descendant's true visual
52
+ // position, which is exactly why `rotation` below exists: it's the one piece of information this
53
+ // hook cannot recover from measurement alone, since Yoga's layout pass and a CSS `transform` are
54
+ // deliberately independent stages, so the caller has to supply it.
55
+ //
56
+ // `rotation` defaults to 0 (measureInWindow's raw result used as-is) — every existing caller that
57
+ // never sits inside a rotated ancestor keeps behaving identically; only a caller that both (a) can
58
+ // end up inside a @tastic/split-screen-style rotated zone and (b) knows which rotation is currently
59
+ // live needs to pass it (see SectionedDropdown's own `rotation` prop for the wiring this expects).
19
60
  //
20
61
  // Also returns `maxHeight`: the actual room available in whichever vertical direction got picked.
21
62
  // Flipping above-vs-below only ever picks the *better* side — on a screen short enough that neither
@@ -27,7 +68,7 @@ export type PopoverVerticalAlign = 'below' | 'above'
27
68
  //
28
69
  // Re-measures on every open (not continuously) — the trigger's screen position can't change while
29
70
  // 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) {
71
+ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight: number, rotation: PopoverRotation = 0) {
31
72
  const triggerRef = useRef<View>(null)
32
73
  const { width: windowWidth, height: windowHeight } = useWindowDimensions()
33
74
  const [align, setAlign] = useState<PopoverAlign>('center')
@@ -52,7 +93,8 @@ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight:
52
93
  }
53
94
 
54
95
  const measure = useCallback(() => {
55
- triggerRef.current?.measureInWindow((x, y, triggerWidth, triggerHeight) => {
96
+ triggerRef.current?.measureInWindow((rawX, rawY, rawWidth, rawHeight) => {
97
+ const { x, y, width: triggerWidth, height: triggerHeight } = rotateRect(rawX, rawY, rawWidth, rawHeight, rotation, windowWidth, windowHeight)
56
98
  const centerX = x + triggerWidth / 2
57
99
  const overflowsRight = centerX + contentWidth / 2 > windowWidth - EDGE_MARGIN
58
100
  const overflowsLeft = centerX - contentWidth / 2 < EDGE_MARGIN
@@ -72,14 +114,14 @@ export function useAutoAlign(open: boolean, contentWidth: number, contentHeight:
72
114
  setMaxHeight(Math.max(above ? roomAbove : roomBelow, 0))
73
115
  setMeasured(true)
74
116
  })
75
- }, [contentWidth, contentHeight, windowWidth, windowHeight])
117
+ }, [contentWidth, contentHeight, windowWidth, windowHeight, rotation])
76
118
 
77
119
  // 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.
120
+ // contentWidth/contentHeight/windowWidth/windowHeight/rotation changed) while already open — e.g.
121
+ // a live window resize, or the device being physically turned while the popover is showing — so it
122
+ // never goes stale while the popover is actually showing. `measured` only resets on a genuine
123
+ // (re)open, via the render-phase check above — not here on every re-measure, so an already-visible
124
+ // popover never flickers hidden again just because the window resized (or rotated) under it.
83
125
  useEffect(() => {
84
126
  if (open) measure()
85
127
  }, [open, measure])