Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift
T
enricobuehler c010139e6e
ci / bun-nix (pull_request) Successful in 49s
ci / web (pull_request) Successful in 1m3s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m38s
ci / rust (pull_request) Successful in 16m9s
feat(client/apple): the gamepad UI moves and colours like the console it mirrors
Four reworks from the first palette-era on-glass review, all iOS-facing:

- Surfaces carry the palette now, not just the text on them: ConsoleGlass
  washes every tier (Liquid Glass tint, pre-26 material, tvOS material)
  with ink.glass — the same colour the desktop console fills its panels
  with — and the close buttons move to an ink-aware consoleGlassBackground.
  The pre-26 branch also gains the focus tint it had silently dropped.
  Stray literals follow: ConnectOverlay text rides ink in the console
  takeover, card shadows soften on pale fields, the focused keycap reads
  onAccent. The online pip stays status-green on purpose.

- The header breathes: title top padding 4/10 -> 10/18 plus shared
  header-spacing and title-bottom helpers mapped from the console shell's
  rhythm, applied to the launcher, settings and add-host alike, with the
  add-host close X re-anchored to the title row.

- Settings, Add Host and the Library present IN PLACE on iOS: one
  persistent aurora whose calm is chased (the console's bg_mix), screens
  as transparent layers with the console's 0.26 s ease-out-cubic push/pop,
  an input drop for the transition, and the controller handed off through
  isActive — no more opaque bottom-up covers, no backdrop teardown.
  macOS keeps its sheets, tvOS its focus-engine covers.

- The settings select is a real band: choice rows mount GamepadOptionBand,
  a spring-driven drum (Animatable body, ring-distance wrap, neighbours
  gated by focus and flight) whose retargeting spring accumulates rapid
  steps into one continuous spin. Reduce Motion falls back to a plain
  crossfade; toggles keep the quiet 14 pt slip.

Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0,
swift test 208 passed / 0 failed. On-glass QA still owed: palette sweep on
a pale palette, transition compositing over materials, drum feel on device.
2026-08-07 13:59:40 +02:00

186 lines
6.8 KiB
Swift

// 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 {
@Environment(\.gamepadInk) private var ink
@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(ink.fg(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))
}
}
// The focused keycap sits on `ink.accent`, so `onAccent` is what reads on it — a dark
// accent palette got black-on-dark with the old literal black.
.foregroundStyle(focused ? ink.onAccent : ink.fg)
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(focused ? AnyShapeStyle(ink.accent) : AnyShapeStyle(ink.fg(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