`buttonGlyph` asked the LIVE controller for its `sfSymbolsName` and fell back to generic letter glyphs, so the moment a DualSense slept, disconnected or ran flat, every ✕/◯ hint turned into A/B under its owner's hands. Under `gamepadUIMode == "always"` — the console UI up with no pad by design — they were never right at all. GamepadManager now remembers the family of the last controller that was actually attached (`DefaultsKey.lastGamepadKind`, written on selection and deliberately never cleared on disconnect), and the legends resolve live → remembered → generic. Not a user-facing setting: a glyph-style picker is one more row asking a question the app can answer itself, and the remembered pad is right essentially always. GamepadGlyphs maps by POSITION, not by name, because that is what `GCExtendedGamepad`'s buttonA/B/X/Y are. So ✕ is role `.a` (the bottom button), and a Switch pad gets Nintendo's transposed labels rather than Xbox letters printed on the wrong physical buttons. The test asserts every symbol name RESOLVES on the running OS. That is the load-bearing one: `Image(systemName:)` draws an unknown name as nothing at all — no crash, no log, no red build — so a typo would silently blank a legend on device and no amount of building would catch it. macOS + tvOS typecheck; the 5 glyph tests pass; the console UI verified opening its Settings screen in the iPad simulator.
110 lines
5.7 KiB
Swift
110 lines
5.7 KiB
Swift
// Button glyphs for the gamepad UI's legends, for a controller that ISN'T currently attached.
|
|
//
|
|
// While a pad is connected the truth is GameController's own `sfSymbolsName` on the live element —
|
|
// nothing here competes with that. The problem this file solves is the other half of the time: the
|
|
// instant `GamepadManager.active` goes nil (the pad slept, its battery died, it was unplugged, or
|
|
// `gamepadUIMode == "always"` put the console UI up with no pad at all) there is no element left to
|
|
// ask, and every legend fell back to the generic letter glyphs — which read as an Xbox pad. A
|
|
// DualSense user watched their ✕/◯ legends turn into A/B the moment the controller dozed off.
|
|
//
|
|
// So: `GamepadManager` remembers the KIND of the last controller that was actually attached
|
|
// (`DefaultsKey.lastGamepadKind`, never cleared on disconnect) and the legends resolve through this
|
|
// table instead. Deliberately NOT a user-facing setting — a "glyph style" picker is one more row in
|
|
// a settings screen to answer a question the app can answer itself, and the remembered pad is right
|
|
// essentially always: people own the controller they last plugged in.
|
|
//
|
|
// Positional, not nominal. `GCExtendedGamepad`'s buttonA/B/X/Y are POSITIONS (A = bottom, B =
|
|
// right, X = left, Y = top), so each family maps its own labels onto those positions — which is why
|
|
// the Nintendo column looks transposed: a Switch pad's bottom button is B and its right one is A.
|
|
|
|
import Foundation
|
|
import GameController
|
|
|
|
/// A face/shoulder button by POSITION, which is what `GCExtendedGamepad` exposes and what a legend
|
|
/// actually means ("press the bottom button"). The label drawn for it is the family's business.
|
|
public enum GamepadButtonRole: Sendable {
|
|
/// Bottom face button — Xbox A, PlayStation ✕, Nintendo B.
|
|
case a
|
|
/// Right face button — Xbox B, PlayStation ◯, Nintendo A.
|
|
case b
|
|
/// Left face button — Xbox X, PlayStation □, Nintendo Y.
|
|
case x
|
|
/// Top face button — Xbox Y, PlayStation △, Nintendo X.
|
|
case y
|
|
case leftShoulder
|
|
case rightShoulder
|
|
|
|
/// The role a `GCExtendedGamepad` key path names, so a caller that already spells its buttons
|
|
/// as key paths (every legend in the gamepad UI does — it reads `sfSymbolsName` off the live
|
|
/// element through one) can reach this table without restating itself. nil for any other
|
|
/// button: the legends only ever name these six, and a role invented for, say, the menu button
|
|
/// would have no honest glyph on half the families.
|
|
///
|
|
/// Compared with `==` rather than matched with `switch`: key paths are reference-typed and
|
|
/// their pattern-matching goes through the generic `Equatable` `~=`, which is easy to send to
|
|
/// an unintended overload. This spelling has exactly one meaning.
|
|
public init?(keyPath: KeyPath<GCExtendedGamepad, GCControllerButtonInput>) {
|
|
if keyPath == \GCExtendedGamepad.buttonA { self = .a }
|
|
else if keyPath == \GCExtendedGamepad.buttonB { self = .b }
|
|
else if keyPath == \GCExtendedGamepad.buttonX { self = .x }
|
|
else if keyPath == \GCExtendedGamepad.buttonY { self = .y }
|
|
else if keyPath == \GCExtendedGamepad.leftShoulder { self = .leftShoulder }
|
|
else if keyPath == \GCExtendedGamepad.rightShoulder { self = .rightShoulder }
|
|
else { return nil }
|
|
}
|
|
}
|
|
|
|
public enum GamepadGlyphs {
|
|
/// The SF Symbol a `role` wears on a `kind` of pad. Every name here is asserted to resolve on
|
|
/// the running OS by `GamepadGlyphTests` — a symbol name that doesn't exist renders as NOTHING
|
|
/// (SwiftUI draws an empty image rather than failing), so a typo would silently blank a legend
|
|
/// on real hardware and never show up in a build.
|
|
public static func symbol(_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType)
|
|
-> String {
|
|
switch role {
|
|
case .leftShoulder: return "l1.rectangle.roundedbottom"
|
|
case .rightShoulder: return "r1.rectangle.roundedbottom"
|
|
case .a, .b, .x, .y: return faceSymbol(role, for: kind)
|
|
}
|
|
}
|
|
|
|
private static func faceSymbol(
|
|
_ role: GamepadButtonRole, for kind: PunktfunkConnection.GamepadType
|
|
) -> String {
|
|
switch kind {
|
|
// PlayStation shapes. ✕ is the BOTTOM button, so it belongs to role `.a` — the mapping
|
|
// people mean when they say "the PlayStation glyphs".
|
|
case .dualSense, .dualSenseEdge, .dualShock4:
|
|
switch role {
|
|
case .a: return "xmark.circle"
|
|
case .b: return "circle.circle"
|
|
case .x: return "square.circle"
|
|
case .y: return "triangle.circle"
|
|
default: return "circle.circle"
|
|
}
|
|
// Nintendo's labels sit transposed on the same positions (bottom = B, right = A,
|
|
// left = Y, top = X) — printing Xbox letters on a Switch pad would name the wrong
|
|
// physical button, which is worse than a generic glyph.
|
|
case .switchPro:
|
|
switch role {
|
|
case .a: return "b.circle"
|
|
case .b: return "a.circle"
|
|
case .x: return "y.circle"
|
|
case .y: return "x.circle"
|
|
default: return "a.circle"
|
|
}
|
|
// Xbox, the Steam pads (Deck included — its ABXY is the Xbox layout), and `.auto`, which
|
|
// is what a client with no remembered pad has. Xbox letters double as the neutral default
|
|
// because they ARE the positional names in `GCExtendedGamepad`.
|
|
case .auto, .xbox360, .xboxOne, .steamController, .steamDeck, .steamController2:
|
|
switch role {
|
|
case .a: return "a.circle"
|
|
case .b: return "b.circle"
|
|
case .x: return "x.circle"
|
|
case .y: return "y.circle"
|
|
default: return "a.circle"
|
|
}
|
|
}
|
|
}
|
|
}
|