feat(apple): gamepad UI v2 — controller settings + add host, aurora, macOS
Sources reorganized (client: Home/Session/Settings/Stores/Support/Trust; kit: Audio/Connection/Gamepad/Input/Support/Video/Views) with the big files split along the same seams. The gamepad mode is couch-complete, and now on macOS too (the living-room Mac case), not just iOS/iPadOS: - GamepadSettingsView: a console-style, fully controller-navigable settings screen (X from the launcher) — up/down moves focus, left/right steps values (clamped, boundary thud), A cycles/toggles, B closes; the focused row shows a one-line description. Backed by GamepadMenuList, the vertical sibling of GamepadCarousel, and SettingsOptions — the option lists hoisted out of SettingsView statics and shared by the touch, tvOS and gamepad settings. - GamepadAddHostView + GamepadKeyboard: register a host end to end with a pad — field rows open an on-screen controller keyboard (dpad grid, A types, X backspaces, B done); the launcher carousel ends in an Add Host tile, so the dead-end "add one with touch first" empty state is gone. - Launcher polish: contextual hint bar with the pad's real button glyphs, controller name + battery chip, one shared console chrome. - GamepadScreenBackground: an animated aurora (TimelineView-driven drifting blobs in the brand's violet family, breathing radii, slow hue shift, legibility scrim; freezes under Reduce Motion). Pure SwiftUI on purpose — a .metal library only bundles reliably in one of the two build systems (SPM vs the xcodeproj's synced folders) these sources compile under. - macOS port: settings/add-host/library present as sized sheets (a macOS sheet takes its content's IDEAL size, and the GeometryReader-driven screens collapsed to nothing), NSScreen-based mode lists, scroll indicators .never (the "always show scroll bars" setting overrides .hidden), tray scrims so scrolled rows dim under the pinned title/hints, extra title clearance, and a PUNKTFUNK_FORCE_GAMEPAD_UI=1 dev hook — launcher/settings/add-host/keyboard/ library render-verified live on a real Mac + LAN hosts. - GamepadMenuInput: X button support, and (re)start now snapshots held buttons so a controller handoff press never fires twice (the B that closed the keyboard no longer also cancels the screen underneath). - Cleanups: one "Connection failed" alert in ContentView instead of one per home screen; HostDiscovery.advertises/unsaved shared by both home screens. - host: can_encode_444 stub for the non-Linux/Windows host build (the macOS synthetic-source loopback used by the Swift tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Geist — the punktfunk brand typeface (the same family the website ships). Bundled as static
|
||||
// OTF weights in this kit's resources and registered with Core Text at first use, so it works
|
||||
// identically in the Xcode app and the `swift run` dev shell (Bundle.module resolves to the
|
||||
// package resource bundle in both). Geist Sans carries titles/UI; Geist Mono carries the technical
|
||||
// readouts — host addresses, status labels, the stream-stats HUD — for the industrial look.
|
||||
//
|
||||
// Licensed under the SIL Open Font License 1.1 (Resources/Fonts/Geist-OFL.txt).
|
||||
|
||||
import CoreText
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public enum BrandFont {
|
||||
public enum Weight {
|
||||
case regular, medium, semibold, bold
|
||||
}
|
||||
|
||||
/// PostScript names of the bundled faces (verified from each OTF's name table). Geist Sans only
|
||||
/// — Geist Mono is intentionally not shipped; the app's typeface is Geist Sans throughout.
|
||||
private static let sansFaces = ["Geist-Regular", "Geist-Medium", "Geist-SemiBold", "Geist-Bold"]
|
||||
|
||||
/// Registered exactly once per process — a static `let` initializer is run lazily and is
|
||||
/// guaranteed thread-safe + run-at-most-once by the runtime.
|
||||
private static let registered: Void = {
|
||||
for face in sansFaces {
|
||||
guard let url = Bundle.module.url(
|
||||
forResource: face, withExtension: "otf", subdirectory: "Fonts") else {
|
||||
#if DEBUG
|
||||
print("BrandFont: bundled face \(face).otf not found — text will fall back to system")
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
var error: Unmanaged<CFError>?
|
||||
if !CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error) {
|
||||
#if DEBUG
|
||||
let message = error?.takeRetainedValue().localizedDescription ?? "unknown error"
|
||||
print("BrandFont: failed to register \(face): \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
/// Force registration before the first `Font.custom` lookup. Cheap to call repeatedly.
|
||||
public static func registerIfNeeded() { _ = registered }
|
||||
|
||||
fileprivate static func sansFace(_ weight: Weight) -> String {
|
||||
switch weight {
|
||||
case .regular: return "Geist-Regular"
|
||||
case .medium: return "Geist-Medium"
|
||||
case .semibold: return "Geist-SemiBold"
|
||||
case .bold: return "Geist-Bold"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Color {
|
||||
/// The punktfunk brand purple (the app-icon lens / website `--brand`). Defined explicitly,
|
||||
/// independent of the asset-catalog accent — `Color.accentColor` resolution is environment- and
|
||||
/// timing-sensitive (it can fall back to system blue), and the brand mark must never drift.
|
||||
/// Light: #6656F2, Dark: #8678F5 (the lighter violet reads better on dark surfaces).
|
||||
static let brand: Color = {
|
||||
#if canImport(UIKit)
|
||||
return Color(UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||
: UIColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||
})
|
||||
#elseif canImport(AppKit)
|
||||
return Color(NSColor(name: nil) { appearance in
|
||||
appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||
? NSColor(red: 0x86 / 255, green: 0x78 / 255, blue: 0xF5 / 255, alpha: 1)
|
||||
: NSColor(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255, alpha: 1)
|
||||
})
|
||||
#else
|
||||
// Non-Apple fallback: the light brand value, so all branches agree on a canonical color.
|
||||
return Color(red: 0x66 / 255, green: 0x56 / 255, blue: 0xF2 / 255)
|
||||
#endif
|
||||
}()
|
||||
}
|
||||
|
||||
public extension Font {
|
||||
/// Geist Sans at an explicit point size, scaling with Dynamic Type relative to `textStyle`.
|
||||
static func geist(
|
||||
_ size: CGFloat, _ weight: BrandFont.Weight = .regular,
|
||||
relativeTo textStyle: TextStyle = .body
|
||||
) -> Font {
|
||||
BrandFont.registerIfNeeded()
|
||||
return .custom(BrandFont.sansFace(weight), size: size, relativeTo: textStyle)
|
||||
}
|
||||
|
||||
/// Geist Sans at a FIXED point size that does not scale with Dynamic Type — for glyphs pinned
|
||||
/// inside a fixed-size container (e.g. the monogram tile), where a scaled letter would overflow.
|
||||
static func geistFixed(_ size: CGFloat, _ weight: BrandFont.Weight = .regular) -> Font {
|
||||
BrandFont.registerIfNeeded()
|
||||
return .custom(BrandFont.sansFace(weight), fixedSize: size)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user