diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index b35097c0..f29d3ad8 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -10,15 +10,29 @@ import SwiftUI #if os(iOS) || os(macOS) || os(tvOS) import GameController -/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via -/// `sfSymbolsName`; a generic fallback before a controller profile resolves. +/// The glyph a button wears in a legend: the ACTIVE controller's own (Xbox "A", DualSense ✕, …) +/// via `sfSymbolsName` while one is attached, else the glyph of the last pad this device ever saw +/// (`GamepadManager.lastKnownKind` → `GamepadGlyphs`), else the caller's generic fallback. +/// +/// The middle rung is the whole point. `active` is nil whenever the pad sleeps, disconnects or +/// runs flat — and permanently under `gamepadUIMode == "always"`, which puts the console UI up +/// with no pad by design — and the fallbacks are letter glyphs, so a DualSense user's ✕/◯ legends +/// used to turn into A/B the moment the controller dozed off. The remembered kind keeps the +/// legends speaking the pad the user actually owns. The `fallback` still covers the genuinely +/// unknown case: a fresh install that has never seen a controller, and any button outside the six +/// `GamepadButtonRole` names. +/// /// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit). @MainActor func buttonGlyph( _ button: KeyPath, fallback: String ) -> String { - GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName - ?? fallback + let manager = GamepadManager.shared + if let live = manager.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName { + return live + } + guard let role = GamepadButtonRole(keyPath: button) else { return fallback } + return GamepadGlyphs.symbol(role, for: manager.lastKnownKind) } /// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadGlyphs.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadGlyphs.swift new file mode 100644 index 00000000..aa8dc52c --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadGlyphs.swift @@ -0,0 +1,109 @@ +// 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) { + 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" + } + } + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift index 3d5c1687..7c45892c 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift @@ -87,6 +87,17 @@ public final class GamepadManager: ObservableObject { /// `lowest_free_index`). Recomputed by `assignPadIndices` whenever `forwarded` changes. private var padIndexByController: [ObjectIdentifier: UInt8] = [:] + /// The kind of the last controller that was actually attached — persisted under + /// `DefaultsKey.lastGamepadKind` and deliberately NEVER cleared on disconnect. The gamepad + /// UI's legends read it (through `GamepadGlyphs`) whenever `active` is nil, so a DualSense + /// user's ✕/◯ hints don't turn into A/B the moment the pad sleeps, and so the legends are + /// right at all under `gamepadUIMode == "always"`, which puts the console UI up with no pad + /// attached by design. `.auto` = nothing has ever been seen on this device (⇒ neutral glyphs). + /// + /// @Published so the legends re-render when a pad of a different family arrives; the screens + /// already observe this object for `active`. + @Published public private(set) var lastKnownKind: PunktfunkConnection.GamepadType + /// The user's pinned controller fingerprint ("" = automatic). Persisted; updating it /// reselects immediately, so a Settings Picker can bind straight to this. @Published public var preferredID: String { @@ -97,12 +108,19 @@ public final class GamepadManager: ObservableObject { } private static let preferredKey = DefaultsKey.gamepadID + private static let lastKindKey = DefaultsKey.lastGamepadKind /// Connect order (identity-keyed) — drives both twin de-dup suffixes and auto-pick. private var connectOrder: [ObjectIdentifier] = [] private var observers: [NSObjectProtocol] = [] private init() { preferredID = UserDefaults.standard.string(forKey: Self.preferredKey) ?? "" + // Stored as an Int (what UserDefaults round-trips losslessly) and validated back into a + // real case: a value written by a NEWER client — a pad family this build has no case for + // — must fall back to the neutral glyphs, not trap on an invalid raw value. + lastKnownKind = (UserDefaults.standard.object(forKey: Self.lastKindKey) as? Int) + .flatMap { UInt32(exactly: $0) } + .flatMap(PunktfunkConnection.GamepadType.init(rawValue:)) ?? .auto observers.append(NotificationCenter.default.addObserver( forName: .GCControllerDidConnect, object: nil, queue: .main ) { [weak self] n in @@ -212,6 +230,13 @@ public final class GamepadManager: ObservableObject { // (list is in connect order). A stale pin falls back to automatic. let pinned = candidates.last { $0.id == preferredID } active = pinned ?? candidates.last + // Remember the family for the legends (see `lastKnownKind`). Only ever WRITTEN, never + // cleared: `active` going nil is precisely the moment the memory has to survive, and a + // pad whose `kind` is genuinely unknown never becomes active in the first place. + if let active, active.kind != lastKnownKind { + lastKnownKind = active.kind + UserDefaults.standard.set(Int(active.kind.rawValue), forKey: Self.lastKindKey) + } // Forwarded set (pf-client-core's `forwarded_ids`): a pin forwards ONLY the pinned pad // (explicit single-player); Automatic forwards every extended controller in connect order // (oldest→newest), so a game's player numbers are stable across hot-plug churn. diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index dc857763..ff897834 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -32,6 +32,15 @@ public enum DefaultsKey { public static let compositor = "punktfunk.compositor" public static let gamepadType = "punktfunk.gamepadType" public static let gamepadID = "punktfunk.gamepadID" + /// The `PunktfunkConnection.GamepadType` raw value of the last controller that was actually + /// attached — written by `GamepadManager` whenever one becomes active, never cleared on + /// disconnect. It exists so the gamepad UI's button legends keep speaking the pad the user + /// owns: the live controller's own `sfSymbolsName` is authoritative while it's connected, but + /// the moment it sleeps or disconnects there is nothing left to ask, and the legends used to + /// snap back to generic letter glyphs (i.e. Xbox) under a DualSense user's hands. Also what + /// makes the legends right at all under `gamepadUIMode == "always"`, where the console UI is + /// up with no pad attached by design. See `GamepadGlyphs`. + public static let lastGamepadKind = "punktfunk.lastGamepadKind" /// Forward this device's controllers to the host at all (default true). Off is for a /// couch whose controller reaches the host another way — USB passthrough such as /// VirtualHere, or a pad plugged into the host — where forwarding as well would give the diff --git a/clients/apple/Tests/PunktfunkKitTests/GamepadGlyphTests.swift b/clients/apple/Tests/PunktfunkKitTests/GamepadGlyphTests.swift new file mode 100644 index 00000000..b4390ad9 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/GamepadGlyphTests.swift @@ -0,0 +1,89 @@ +// The remembered-controller glyph table. +// +// The load-bearing assertion here is that every SF Symbol name RESOLVES. `Image(systemName:)` +// renders a name the OS doesn't know as NOTHING at all — no crash, no log, no red build — so a +// typo in the table would silently blank a legend cell on real hardware and be invisible until +// someone looked at a device. This test is the only thing standing between that and a release. + +import GameController +import XCTest +@testable import PunktfunkKit + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +final class GamepadGlyphTests: XCTestCase { + private let roles: [GamepadButtonRole] = [.a, .b, .x, .y, .leftShoulder, .rightShoulder] + + /// Does the running OS actually have this symbol? + private func symbolExists(_ name: String) -> Bool { + #if canImport(UIKit) + return UIImage(systemName: name) != nil + #elseif canImport(AppKit) + return NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + #else + return true + #endif + } + + func testEveryGlyphNameResolvesOnThisOS() { + for kind in PunktfunkConnection.GamepadType.allCases { + for role in roles { + let name = GamepadGlyphs.symbol(role, for: kind) + XCTAssertTrue( + symbolExists(name), + "SF Symbol \"\(name)\" (\(role), \(kind)) does not resolve — the legend cell " + + "would render blank on device") + } + } + } + + /// ✕ is the BOTTOM button on a PlayStation pad, which is `GCExtendedGamepad.buttonA` — the + /// whole point of the table being positional. Getting this backwards would print ◯ where the + /// user has to press ✕. + func testPlayStationFaceButtonsAreShapesInPositionalOrder() { + for kind in [PunktfunkConnection.GamepadType.dualSense, .dualSenseEdge, .dualShock4] { + XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "xmark.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "circle.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "square.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "triangle.circle") + } + } + + /// Nintendo's labels sit transposed on the same physical positions: the bottom button (role + /// `.a`) is labelled B, and the right one (role `.b`) is labelled A. + func testSwitchFaceButtonsAreTransposed() { + XCTAssertEqual(GamepadGlyphs.symbol(.a, for: .switchPro), "b.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.b, for: .switchPro), "a.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.x, for: .switchPro), "y.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.y, for: .switchPro), "x.circle") + } + + /// `.auto` is what a device that has never seen a controller reports, and Xbox letters are the + /// neutral default — they are also the positional names `GCExtendedGamepad` itself uses. + func testUnknownAndXboxFamiliesUseLetters() { + for kind in [PunktfunkConnection.GamepadType.auto, .xbox360, .xboxOne, .steamDeck] { + XCTAssertEqual(GamepadGlyphs.symbol(.a, for: kind), "a.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.b, for: kind), "b.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.x, for: kind), "x.circle") + XCTAssertEqual(GamepadGlyphs.symbol(.y, for: kind), "y.circle") + } + } + + /// The key-path bridge the legends reach this table through (`buttonGlyph` spells its buttons + /// as key paths). A wrong mapping here would print the wrong button on every family at once. + func testRolesResolveFromExtendedGamepadKeyPaths() { + XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonA), .a) + XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonB), .b) + XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonX), .x) + XCTAssertEqual(GamepadButtonRole(keyPath: \.buttonY), .y) + XCTAssertEqual(GamepadButtonRole(keyPath: \.leftShoulder), .leftShoulder) + XCTAssertEqual(GamepadButtonRole(keyPath: \.rightShoulder), .rightShoulder) + // A button outside the six the legends name has no honest glyph on every family, so it + // falls through to the caller's own fallback rather than guessing. + XCTAssertNil(GamepadButtonRole(keyPath: \.leftTrigger)) + } +}