From 5db3b3c4fdda2253ff2fa5caeeb39216ccaeec28 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 08:05:14 +0200 Subject: [PATCH] feat(apple): the controls legend is clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every hint cell that names an action is a real button on iOS/iPadOS/macOS. The legend already lists every action a screen has, in one fixed place, so a user without a pad in their hands — an iPad on a stand, a Mac driven by trackpad, anyone running `gamepadUIMode == "always"` — was reading a complete menu they could not press. tvOS keeps them inert deliberately. There is no pointer there, so a tappable cell would have to be FOCUSABLE, and that puts six new stops in the path of a focus engine whose flow on these screens is load-bearing and hard-won — while every action in the legend already has a native route (select, Menu, Play/Pause, the focusable tab pills). Cells that name an INPUT rather than an action stay labels: "↔ Adjust" is the stick itself, and "A Type" over the on-screen keyboard has no tap equivalent because a touch user types by tapping the keycap. Two details that are load-bearing rather than tidy: the decorative hairline gets `allowsHitTesting(false)` (it sits on top of the cells), and the press style's `contentShape` sits below its `scaleEffect` so shrinking the artwork cannot move the target out from under a resting finger and lose the touch-up. macOS + tvOS typecheck; 272 tests pass; console UI verified opening Settings in the iPad simulator. --- .../Home/GamepadAddHostView.swift | 28 ++++++-- .../PunktfunkClient/Home/GamepadChrome.swift | 64 ++++++++++++++++--- .../Home/GamepadHomeView.swift | 15 ++++- .../Home/LibraryCoverflowView.swift | 14 ++-- .../Settings/GamepadSettingsView.swift | 27 ++++++-- 5 files changed, 122 insertions(+), 26 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 8921c700..6a2c602a 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -147,17 +147,28 @@ struct GamepadAddHostView: View { // binding on appear — new identity forces a rewire to the new field. .id(editing) GamepadHintBar(hints: [ + // "Type" names what A does to the key under the keyboard's cursor. There is + // no tap equivalent — a touch user types by tapping the keycap itself — so + // this one cell stays a label. .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"), - .init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"), - .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), + .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 { GamepadHintBar(hints: [ - .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"), - .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"), + .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) } @@ -267,6 +278,15 @@ struct GamepadAddHostView: View { withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil } } + /// The legend's Delete cell (iOS/macOS). Applied to the field's binding rather than routed + /// into `GamepadKeyboard`: the keyboard's X does exactly this to the same binding, and + /// reaching into its state to trigger it would need a whole callback channel for one edit. + private func backspace(_ id: String) { + let binding = editingBinding(id) + guard !binding.wrappedValue.isEmpty else { return } + binding.wrappedValue.removeLast() + } + private func editingBinding(_ id: String) -> Binding { switch id { case "name": return $name diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index 180b3998..7f73c9ad 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -180,6 +180,10 @@ extension EnvironmentValues { struct GamepadHint: Identifiable { let glyph: String let text: String + /// What tapping/clicking this cell does — the same thing its button does. Optional because a + /// few legend cells NAME an input rather than an action ("↔ Adjust" is the stick itself; + /// there is no single thing a tap on it could mean), and those stay inert labels. + var action: (() -> Void)? = nil var id: String { glyph + text } } @@ -197,23 +201,67 @@ struct GamepadHintBar: View { var body: some View { HStack(spacing: 18) { ForEach(hints) { hint in - HStack(spacing: 7) { - Image(systemName: hint.glyph) - .font(.system(size: metrics.hintGlyphFont)) - .foregroundStyle(ink.fg) - Text(hint.text) - } - .fixedSize() // keep glyph + label together; never truncate a hint mid-word + cell(hint) } } .font(.geist(metrics.hintTextFont, .semibold, relativeTo: .subheadline)) .foregroundStyle(ink.fg(0.85)) .padding(metrics.hintPad) .consoleGlass(Capsule()) - .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1)) + // The hairline is DECORATION and sits on top of the cells, so it must never take a touch. + // Spelled out rather than left to defaults, because a swallowed touch in this bar is + // invisible — the legend simply stops doing anything. + .overlay(Capsule().strokeBorder(ink.fg(0.12), lineWidth: 1).allowsHitTesting(false)) + } + + /// A cell is a button where it has somewhere to go, and a plain label otherwise (see the type + /// comment for why tvOS is always the latter). + @ViewBuilder private func cell(_ hint: GamepadHint) -> some View { + #if os(tvOS) + label(hint) + #else + if let action = hint.action { + Button(action: action) { label(hint) } + .buttonStyle(HintCellStyle()) + .accessibilityLabel(hint.text) + } else { + label(hint) + } + #endif + } + + private func label(_ hint: GamepadHint) -> some View { + HStack(spacing: 7) { + Image(systemName: hint.glyph) + .font(.system(size: metrics.hintGlyphFont)) + .foregroundStyle(ink.fg) + Text(hint.text) + } + .fixedSize() // keep glyph + label together; never truncate a hint mid-word + // The tappable area covers the gap between glyph and label, not just their painted + // pixels — a legend cell is small enough already. + .contentShape(Rectangle()) } } +#if !os(tvOS) +/// Press feedback for a legend cell. Deliberately quiet — the bar is chrome, and a cell that lit +/// up like a primary button would pull the eye off the content it describes. +/// +/// `contentShape` sits BELOW the scale so the hit region stays the unscaled layout bounds: a press +/// animation that shrinks the artwork must never move the target out from under a resting finger, +/// or the touch-up lands outside and SwiftUI discards the tap. +private struct HintCellStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .opacity(configuration.isPressed ? 0.55 : 1) + .scaleEffect(configuration.isPressed ? 0.94 : 1) + .animation(.smooth(duration: 0.14), value: configuration.isPressed) + .contentShape(Rectangle()) + } +} +#endif + /// The console backdrop: a living aurora drifting slowly over black so it reads as ambience behind /// the cards, never as content. On iOS 18 / macOS 15+ it's an animated `MeshGradient` — a continuous /// silk of colour whose control points wander on slow, out-of-phase sinusoids — finished with an diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 3fabab25..7fcd1ecc 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -420,13 +420,22 @@ struct GamepadHomeView: View { case .rescan: "Rescan" default: nil } + // Every cell's action re-resolves the selection when it FIRES rather than closing over the + // one this render saw: the legend is rebuilt on selection changes, but a tap landing in + // the same frame as a carousel move would otherwise activate the tile that was selected a + // moment ago — the one failure mode a launcher cannot afford. var hints = [GamepadHint( glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), - text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))] + text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"), + action: { tiles.first { $0.id == selection }?.activate() })] if libraryEnabled, selected?.hasLibrary == true { - hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")) + hints.append(.init( + glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library", + action: { openLibraryForSelected() })) } - hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings")) + hints.append(.init( + glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings", + action: { showSettings = true })) return hints } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index b2fbe2cc..51af4f06 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -204,13 +204,19 @@ struct LibraryCoverflowView: View { private var hints: [GamepadHint] { var hints: [GamepadHint] = [] - if onLaunch != nil { + if let onLaunch { // You *open* a launcher and *launch* a game — the hint follows the focused entry. let opens = games.first { $0.id == selection }?.isLauncher == true - hints.append( - .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch")) + hints.append(.init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch", + // Reads `selection` when it fires, not when the legend was built (see the + // launcher's twin) — and does nothing with no title centred, which is exactly + // what A does. + action: { if let id = selection { onLaunch(id) } })) } - hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close")) + hints.append(.init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close", + action: { onDismiss?() })) return hints } } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 2208f30c..c55df340 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -332,26 +332,39 @@ struct GamepadSettingsView: View { // shoulders exist at all (see `showsSectionHint`). let sections: [GamepadHint] = showsSectionHint ? [.init(glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"), - text: "Section")] + text: "Section", action: { step(tabBy: 1) })] : [] // A dimmed row takes neither, so offering them would be the same lie the row itself // used to tell — only Done remains, and the detail line says what to turn on first. guard rows.first(where: { $0.id == focusID })?.enabled ?? true else { return sections - + [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done")] + + [.init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done", + action: { back() })] } return sections + [ + // The stick itself, not an action — nothing to tap (see GamepadHint.action). .init(glyph: "arrow.left.and.right", text: "Adjust"), - .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), - .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), + .init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change", + action: { if let focusID { activate(id: focusID) } }), + .init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done", + action: { back() }), ] } guard !store.hosts.isEmpty else { - return [.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back")] + return [.init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back", + action: { back() })] } return [ - .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin"), - .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back"), + .init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Pin / Unpin", + action: { if let focusID { activate(id: focusID) } }), + .init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back", + action: { back() }), ] }