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:
@@ -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() }),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user