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
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.
186 lines
6.8 KiB
Swift
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
|