133e25849d
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>
102 lines
4.5 KiB
Swift
102 lines
4.5 KiB
Swift
// 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)
|
|
}
|
|
}
|