Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift
T
enricobuehler 4a9a1c3ed4
ci / docs-site (pull_request) Successful in 1m10s
ci / rust-arm64 (pull_request) Successful in 1m18s
apple / swift (pull_request) Successful in 1m25s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m59s
android / android (pull_request) Successful in 5m53s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m36s
ci / rust (pull_request) Successful in 9m57s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m41s
feat(clients/gamepad-ui): multi-tone palettes, and a UI that takes its colours from them
The first pass built each palette by rotating ONE colour field's hue, and it
showed: every option was a single tone at several brightnesses, which reads flat
next to any real gradient. A palette is now an ordered ramp of DISTINCT hues.
The 4×4 mesh samples that ramp along the diagonal with a fixed per-cell offset
table, so neighbouring cells land on different parts of it and the colours pool
and swirl instead of banding; the control points' existing drift then moves the
pools around. Violet keeps its explicit sixteen colours, so the default is
untouched.

Twelve of them now, dark first then pale: Violet, Nebula, Abyss, Ember, Moss,
Graphite, then Holo, Sunset, Bloom, Dawn, Mint, Opal. Holo and Sunset are
straight takes on the two reference gradients — foil and poster.

`every_palette_is_multi_tone` measures the hue spread across all sixteen cells
and fails under 45° (20° for Graphite and Opal, which are meant to be
restrained). It caught Ember at 35°, all reds and oranges — the very flatness
this rework exists to remove — and Graphite at 3° despite a comment claiming it
drifted cool to warm. Both were rebuilt until the numbers matched the prose.

The UI follows the palette now, rather than wearing brand violet over whatever
happens to be behind it. Each palette carries an accent and a light flag, and an
Ink derived from those (foreground, accent, on-accent, glass, scrim and its
strength) is published to the whole tree — a thread-local in the console, an
environment value on Apple, a CompositionLocal on Android. Pale palettes flip
the ink: dark text on white frost, with the materials, tray scrims and every
wash that sits under text following suit.

Three things only the renders could have told us:

  - Additive blending blows out over a pale ground. Android's blobs and Apple's
    legacy field composite with Plus/plusLighter, which over near-white
    saturates every blob to white — Holo rendered as a grey wash. Pale palettes
    blend normally.
  - A white scrim at the dark field's strength BLEACHES the gradient. Mixing
    toward black at 0.4 reads as depth; toward white at 0.4 destroys the chroma
    it is drawn over. The scrim now carries a per-palette strength.
  - White glass over a bright field has far less separating it from its backdrop
    than dark glass over a dark one, and needed more body.

Verified: console build + clippy -D warnings + 173 tests, Apple build + 200
tests + an iOS-triple typecheck, Android compile + 62 tests, and eyeball passes
on real renders of both the vivid and the pale ends (console CPU rasters; a new
Roborazzi light-palette scene, which is what exposed the blend-mode bug).
2026-08-06 15:55:26 +02:00

184 lines
6.6 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))
}
}
.foregroundStyle(focused ? Color.black : 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