feat(apple): the controls legend is clickable

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.
This commit is contained in:
2026-08-10 08:05:14 +02:00
parent ffb1ecfebe
commit 5db3b3c4fd
5 changed files with 122 additions and 26 deletions
@@ -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<String> {
switch id {
case "name": return $name
@@ -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
@@ -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
}
@@ -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
}
}
@@ -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() }),
]
}