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,182 @@
|
||||
// A controller-driven on-screen keyboard for the gamepad UI's text fields (iOS/iPadOS only) —
|
||||
// iOS has no system keyboard a game controller can drive (the tvOS fullscreen entry doesn't
|
||||
// exist here), so without this, adding a host from the couch would end with "now touch the
|
||||
// screen". Dpad/stick moves a key cursor over a fixed grid, A types, X backspaces, B/Y confirms.
|
||||
// Lowercase + digits + the hostname/address punctuation is deliberately the whole character set:
|
||||
// these fields hold names, addresses and ports, not prose.
|
||||
//
|
||||
// Edits are applied to the binding live (the caller's field row shows every keystroke), so
|
||||
// closing the keyboard is always "done" — there is no separate cancel/commit step to get wrong.
|
||||
// Touch stays a fallback: every keycap is tappable.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct GamepadKeyboard: View {
|
||||
@Binding var text: String
|
||||
/// Restricts typed characters (e.g. digits for a port field); backspace always works.
|
||||
var allowed: CharacterSet?
|
||||
/// B / Y / the Done key — the binding already holds the final text.
|
||||
let onDone: () -> Void
|
||||
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
@State private var haptics = MenuHaptics(manager: .shared)
|
||||
@State private var cursor = GridPos(row: 1, col: 0) // opens on "q"
|
||||
@State private var pressTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
#if os(iOS)
|
||||
/// `.compact` (landscape phone): shorter keycaps so the tray leaves room for the field rows.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||
#endif
|
||||
|
||||
private struct GridPos: Hashable {
|
||||
var row: Int
|
||||
var col: Int
|
||||
}
|
||||
|
||||
private enum Key: Hashable {
|
||||
case char(Character)
|
||||
case space
|
||||
case backspace
|
||||
case done
|
||||
}
|
||||
|
||||
/// Digits first (addresses/ports), then letters; the last char column carries the
|
||||
/// hostname/address punctuation.
|
||||
private static let rows: [[Key]] = [
|
||||
Array("1234567890").map(Key.char),
|
||||
Array("qwertyuiop").map(Key.char),
|
||||
Array("asdfghjkl-").map(Key.char),
|
||||
Array("zxcvbnm._:").map(Key.char),
|
||||
[.space, .backspace, .done],
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: compact ? 5 : 7) {
|
||||
ForEach(Self.rows.indices, id: \.self) { r in
|
||||
HStack(spacing: compact ? 5 : 7) {
|
||||
ForEach(Self.rows[r].indices, id: \.self) { c in
|
||||
keycap(Self.rows[r][c], focused: cursor == GridPos(row: r, col: c))
|
||||
.onTapGesture {
|
||||
cursor = GridPos(row: r, col: c)
|
||||
press(Self.rows[r][c])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 560)
|
||||
.padding(compact ? 10 : 14)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.environment(\.colorScheme, .dark)
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.12), lineWidth: 1)
|
||||
}
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
.onAppear {
|
||||
wire()
|
||||
input.start()
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keycaps
|
||||
|
||||
@ViewBuilder private func keycap(_ key: Key, focused: Bool) -> some View {
|
||||
Group {
|
||||
switch key {
|
||||
case .char(let c):
|
||||
Text(String(c)).font(.geistFixed(18, .medium))
|
||||
case .space:
|
||||
Image(systemName: "space")
|
||||
case .backspace:
|
||||
Image(systemName: "delete.left")
|
||||
case .done:
|
||||
Label("Done", systemImage: "checkmark")
|
||||
.font(.geist(15, .semibold, relativeTo: .callout))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(focused ? Color.black : .white)
|
||||
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08)))
|
||||
}
|
||||
.animation(.smooth(duration: 0.12), value: focused)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
private func wire() {
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { press(Self.rows[cursor.row][cursor.col]) }
|
||||
input.onTertiary = { press(.backspace) }
|
||||
input.onSecondary = onDone
|
||||
input.onBack = onDone
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
var next = cursor
|
||||
switch direction {
|
||||
case .left: next.col -= 1
|
||||
case .right: next.col += 1
|
||||
case .up, .down:
|
||||
let row = cursor.row + (direction == .down ? 1 : -1)
|
||||
guard row >= 0, row < Self.rows.count else { return refuse() }
|
||||
// Map the column proportionally between rows of different widths, so e.g. Done
|
||||
// (rightmost of 3) goes up to the rightmost letters, not to "e".
|
||||
let from = max(1, Self.rows[cursor.row].count - 1)
|
||||
let to = Self.rows[row].count - 1
|
||||
next = GridPos(
|
||||
row: row,
|
||||
col: Int((Double(cursor.col) * Double(to) / Double(from)).rounded()))
|
||||
}
|
||||
guard next.row >= 0, next.row < Self.rows.count,
|
||||
next.col >= 0, next.col < Self.rows[next.row].count
|
||||
else { return refuse() }
|
||||
cursor = next
|
||||
haptics.move()
|
||||
}
|
||||
|
||||
private func press(_ key: Key) {
|
||||
switch key {
|
||||
case .char(let c):
|
||||
if let allowed, !c.unicodeScalars.allSatisfy(allowed.contains) { return refuse() }
|
||||
text.append(c)
|
||||
case .space:
|
||||
if let allowed, !allowed.contains(" ") { return refuse() }
|
||||
text.append(" ")
|
||||
case .backspace:
|
||||
guard !text.isEmpty else { return refuse() }
|
||||
text.removeLast()
|
||||
case .done:
|
||||
haptics.confirm()
|
||||
onDone()
|
||||
return
|
||||
}
|
||||
pressTick &+= 1
|
||||
haptics.move()
|
||||
}
|
||||
|
||||
/// Refused input (edge of the grid, a disallowed character, deleting nothing).
|
||||
private func refuse() {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user