The form screens' pinned title and legend lost their gradient blur when
GamepadTrayScrim went (2590238b): the scrim was `.ultraThinMaterial`, and a
material by definition lifts and tints whatever it blurs — it read grey over
the aurora, and washed with the palette's ground it read coloured. There is
no public way to blur a backdrop without that tint, so the layer was deleted
rather than re-tuned, and the rows have scrolled to a raw edge since.
Glur's GlurView blurs the backdrop through a gradient with NO material stage
on top — the rows soften as they slide under the pinned trays and nothing
carries a colour. Three things about the adoption are deliberate:
- It is the library's GlurBackdrop product, which uses a private API
(a variable-blur CAFilter on UIKit/AppKit). The public `.glur()`
modifier is a Metal shader on a view's OWN content and silently no-ops
over platform-backed views like ScrollView, so it cannot reach a
backdrop at all. GlurBackdrop ships as a separate module precisely so
adopters can weigh the App Store exposure; it disables hit testing
itself, so the band never eats a touch.
- The dependency is pinned by REVISION: GlurBackdrop exists only on main —
no release carries it (the newest tag, `1.1`, predates it and is not
three-component semver anyway, so `from:` resolution stops at 1.0.4,
which doesn't have the product). Wired into BOTH manifests — the
package for `swift build`, and the xcodeproj (remote package reference
+ per-target product deps) for the app targets, which compile these
sources themselves and know nothing of Package.swift's dependencies.
- The band takes NO content-side overhang, unlike the scrim it replaces —
and dropping the scrim's hard-won -44/-72 runway is the point, not an
oversight. That runway existed because a material carries body at every
alpha and had to dissolve OUTSIDE the tray; carried over here it blurred
fully-visible rows at rest (first on-glass verdict). Glur's ramp reaches
literal zero, so the band ends exactly at the tray's layout edge: open
content untouched, rows soften only where they actually slide under.
The 80 pt outer bleed and the pinned shape
(`transaction { $0.animation = nil }`) are kept — full-bleed by LAYOUT
rather than `.ignoresSafeArea()`, so a push never shows the band
growing into place.
Mounted exactly where the scrim was: settings, add-host and pair trays
(top + bottom) and the library title (top). Glur is attributed on the
Acknowledgements screen alongside the bundled font — it is a Swift package,
so the Rust-closure notices generator rightly knows nothing about it.
macOS build, tvOS typecheck; band placement and the blur itself verified on
the iPad on glass.
329 lines
14 KiB
Swift
329 lines
14 KiB
Swift
// The gamepad-driven PIN pairing screen (iOS/iPadOS/macOS) — the controller counterpart of
|
|
// PairSheet, and the reason a console-UI user can pair at all.
|
|
//
|
|
// PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives those natively, but
|
|
// on iOS/macOS a controller cannot reach a text field, type into it, or press the button
|
|
// underneath — so for anyone in the console UI, pairing (the ONE thing standing between a fresh
|
|
// install and a first stream) ended at "now touch the screen". This screen is the same ceremony
|
|
// wearing the gamepad UI's own vocabulary: the vertical focus list from the settings/add-host
|
|
// screens, A on a field to open GamepadKeyboard in a bottom tray, B to peel one layer.
|
|
//
|
|
// Structure deliberately mirrors GamepadAddHostView field for field — the two screens are the same
|
|
// interaction (a short form, typed with a pad, committed by an action row) and a user who has
|
|
// added a host should recognise this immediately. The ceremony itself is shared with PairSheet
|
|
// (`PairCeremony`), so the two presentations can never disagree about what a wrong PIN means.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
#if os(iOS) || os(macOS)
|
|
|
|
struct GamepadPairView: View {
|
|
@Environment(\.gamepadInk) private var ink
|
|
@Environment(\.gamepadMetrics) private var metrics
|
|
@Environment(\.displayBottomInset) private var displayBottomInset
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
|
let host: StoredHost
|
|
/// Called with the verified host fingerprint after a successful ceremony — the caller pins it
|
|
/// and connects (ContentView's `handlePaired`).
|
|
let onPaired: (Data) -> Void
|
|
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet) falls back to the
|
|
/// environment dismiss.
|
|
var close: (() -> Void)?
|
|
/// Whether this screen owns the controller — false while the shell is mid-transition or the
|
|
/// connect takeover is up (see GamepadAddHostView's twin).
|
|
var controllerActive = true
|
|
|
|
#if os(iOS)
|
|
/// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits.
|
|
@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 to fit the tray
|
|
#endif
|
|
|
|
@StateObject private var ceremony = PairCeremony()
|
|
@State private var pin = ""
|
|
#if os(macOS)
|
|
@State private var clientName = Host.current().localizedName ?? "Mac"
|
|
#else
|
|
@State private var clientName = UIDevice.current.name
|
|
#endif
|
|
@State private var focusID: String?
|
|
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
|
@State private var editing: String?
|
|
|
|
var body: some View {
|
|
GamepadMenuList(
|
|
items: rows,
|
|
focusID: $focusID,
|
|
onActivate: { activate(id: $0.id) },
|
|
onBack: { performClose() },
|
|
// A ceremony in flight also takes the list out of the loop: `pair()` blocks on a
|
|
// background thread and its result rewrites this screen, so letting B peel a layer
|
|
// or A fire a second ceremony underneath it would race the completion.
|
|
isActive: controllerActive && editing == nil && !ceremony.busy
|
|
) { row, focused in
|
|
rowView(row, focused: focused)
|
|
.frame(maxWidth: metrics.rowMaxWidth)
|
|
.padding(.horizontal, 24)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.safeAreaInset(edge: .top, spacing: 0) {
|
|
header
|
|
.padding(.horizontal, 24)
|
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
|
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background { GamepadTrayBlur(edge: .top) }
|
|
}
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
bottomTray
|
|
// Equal distance from the left and bottom edges for the legend pill (see
|
|
// GamepadHomeView).
|
|
.padding(.horizontal, compact ? 12 : 18)
|
|
.padding(
|
|
.bottom,
|
|
gamepadLegendBottomPadding(
|
|
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
|
.padding(.top, compact ? 6 : 10)
|
|
.background { GamepadTrayBlur(edge: .bottom) }
|
|
}
|
|
// Hosted in the shell, the field is the shell's own (see GamepadAddHostView's twin).
|
|
.background {
|
|
if !hostedInShell { GamepadFormBackground() }
|
|
}
|
|
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
|
|
// pale palette flips all of them, and no leaf should have to read the setting.
|
|
.gamepadPaletteInk()
|
|
// A PIN is short; cap it so the row can't grow absurd on a stuck key.
|
|
.onChange(of: pin) { _, value in
|
|
if value.count > Self.maxPINLength { pin = String(value.prefix(Self.maxPINLength)) }
|
|
}
|
|
// Any dismissal path abandons an in-flight ceremony — a late success must not pin and
|
|
// connect to a host the user backed out of.
|
|
.onDisappear { ceremony.abandon() }
|
|
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
|
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
|
.background {
|
|
Button("Cancel") { performClose() }
|
|
.keyboardShortcut(.cancelAction)
|
|
.buttonStyle(.plain)
|
|
.frame(width: 0, height: 0)
|
|
.opacity(0)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
|
|
/// Generous next to the host's 4 digits: the PIN length is the HOST's business (a future one
|
|
/// may well be longer), so this is a runaway guard, not a validator. Rejecting a correct PIN
|
|
/// locally would be a far worse failure than sending a wrong one, which the host just refuses.
|
|
private static let maxPINLength = 12
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
|
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
|
Text("Pair with \(host.displayName)")
|
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
|
.foregroundStyle(ink.fg)
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
if !compact {
|
|
Text("The PIN is shown in the host's web console (port 47992 → Pairing). "
|
|
+ "Pairing verifies both sides at once — no fingerprint comparison needed.")
|
|
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
|
.foregroundStyle(ink.fg(0.55))
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: metrics.rowMaxWidth * 0.72, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The keyboard tray while editing, the status line + controls legend otherwise.
|
|
@ViewBuilder private var bottomTray: some View {
|
|
if let editing {
|
|
VStack(spacing: 10) {
|
|
GamepadKeyboard(
|
|
text: editingBinding(editing),
|
|
allowed: allowedCharacters(editing),
|
|
onDone: { closeKeyboard() })
|
|
// Fresh keyboard per field (see GamepadAddHostView) — the tray's input wiring
|
|
// captured the previous binding on appear.
|
|
.id(editing)
|
|
GamepadHintBar(hints: [
|
|
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
|
.init(
|
|
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
|
action: { backspace(editing) }),
|
|
.init(
|
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
|
action: { closeKeyboard() }),
|
|
])
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
statusLine
|
|
GamepadHintBar(hints: [
|
|
.init(
|
|
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
|
action: { if let focusID { activate(id: focusID) } }),
|
|
.init(
|
|
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel",
|
|
action: { performClose() }),
|
|
])
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
/// What the ceremony is doing, in the slot the settings screen gives its detail line. Reserves
|
|
/// its space so the legend never jumps when a failure arrives.
|
|
@ViewBuilder private var statusLine: some View {
|
|
Group {
|
|
if ceremony.busy {
|
|
HStack(spacing: 8) {
|
|
ProgressView().controlSize(.small).tint(ink.fg(0.7))
|
|
Text("Pairing with \(host.displayName)…").foregroundStyle(ink.fg(0.7))
|
|
}
|
|
} else if let error = ceremony.errorText {
|
|
Text(error).foregroundStyle(.red)
|
|
} else {
|
|
// Placeholder keeps the reserved height honest under `lineLimit(2)`.
|
|
Text(" ").foregroundStyle(.clear)
|
|
}
|
|
}
|
|
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
|
.lineLimit(2, reservesSpace: true)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(maxWidth: metrics.rowMaxWidth, alignment: .leading)
|
|
.animation(.smooth(duration: 0.2), value: ceremony.errorText)
|
|
}
|
|
|
|
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
|
|
/// the environment dismiss under a macOS sheet.
|
|
private func performClose() {
|
|
ceremony.abandon()
|
|
if let close { close() } else { dismiss() }
|
|
}
|
|
|
|
// MARK: - Rows
|
|
|
|
private struct Row: Identifiable {
|
|
let id: String
|
|
let label: String
|
|
var value = ""
|
|
var placeholder = ""
|
|
var isAction = false
|
|
}
|
|
|
|
private var rows: [Row] {
|
|
[
|
|
Row(id: "pin", label: "PIN", value: pin, placeholder: "Shown in the web console"),
|
|
Row(
|
|
id: "name", label: "Device name", value: clientName,
|
|
placeholder: "How the host lists this device"),
|
|
Row(id: "pair", label: "Pair & Connect", isAction: true),
|
|
]
|
|
}
|
|
|
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
|
let m = metrics
|
|
return HStack(spacing: 14) {
|
|
if row.isAction {
|
|
Label("Pair & Connect", systemImage: "lock.shield")
|
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
|
.foregroundStyle(canPair ? ink.accent : ink.fg(0.35))
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
Text(row.label)
|
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
|
.foregroundStyle(ink.fg)
|
|
Spacer(minLength: 12)
|
|
Text(row.value.isEmpty ? row.placeholder : row.value)
|
|
.font(.geistFixed(m.valueFont, .medium))
|
|
.foregroundStyle(row.value.isEmpty ? ink.fg(0.35) : ink.fg)
|
|
.lineLimit(1)
|
|
.truncationMode(.head) // keep the end of a long name visible while typing
|
|
if editing == row.id {
|
|
// The live-edit caret: this row is what the keyboard tray is typing into.
|
|
Rectangle()
|
|
.fill(ink.accent)
|
|
.frame(width: 2, height: m.labelFont + 2)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, m.rowHPad)
|
|
.padding(.vertical, m.rowVPad)
|
|
.consoleGlass(
|
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
|
tint: (focused || editing == row.id) ? ink.accent(0.30) : nil,
|
|
interactive: focused)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
|
.strokeBorder(
|
|
editing == row.id ? ink.accent(0.7) : ink.fg(focused ? 0.28 : 0.06),
|
|
lineWidth: 1)
|
|
}
|
|
.scaleEffect(focused ? 1.0 : 0.98)
|
|
.animation(.smooth(duration: 0.18), value: focused)
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func activate(id: String) {
|
|
guard !ceremony.busy else { return }
|
|
switch id {
|
|
case "pair":
|
|
guard canPair else {
|
|
// Not pairable yet — jump straight to what's missing instead of a dead press,
|
|
// matching the add-host screen's Add row.
|
|
focusID = "pin"
|
|
openKeyboard("pin")
|
|
return
|
|
}
|
|
ceremony.run(host: host.address, port: host.port, pin: pin, clientName: clientName) {
|
|
fingerprint in
|
|
onPaired(fingerprint)
|
|
// NOT `performClose()`: that abandons the ceremony, and this IS the ceremony's
|
|
// success. Closing is all that's left to do.
|
|
if let close { close() } else { dismiss() }
|
|
}
|
|
default:
|
|
openKeyboard(id)
|
|
}
|
|
}
|
|
|
|
private var canPair: Bool {
|
|
!pin.trimmingCharacters(in: .whitespaces).isEmpty && !ceremony.busy
|
|
}
|
|
|
|
private func openKeyboard(_ id: String) {
|
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id }
|
|
}
|
|
|
|
private func closeKeyboard() {
|
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil }
|
|
}
|
|
|
|
private func editingBinding(_ id: String) -> Binding<String> {
|
|
id == "pin" ? $pin : $clientName
|
|
}
|
|
|
|
/// The legend's Delete cell — see GamepadAddHostView's twin for why this edits the binding
|
|
/// rather than reaching into the keyboard.
|
|
private func backspace(_ id: String) {
|
|
let binding = editingBinding(id)
|
|
guard !binding.wrappedValue.isEmpty else { return }
|
|
binding.wrappedValue.removeLast()
|
|
}
|
|
|
|
/// What the keyboard may type per field: a PIN is digits; a device name is free-form.
|
|
private func allowedCharacters(_ id: String) -> CharacterSet? {
|
|
id == "pin" ? CharacterSet(charactersIn: "0123456789") : nil
|
|
}
|
|
}
|
|
#endif
|