Asked for by a field user: "make the iPadOS client compatible with keyboard to select games with keyboard arrows, enter to launch". An iPad on a Magic Keyboard and a couch Mac are the same situation the console layout was built for — a screen driven from a distance with a fixed set of directional inputs — and the cursor/confirm/back model already exists here for the pad. A keyboard is a third input onto it, not a new navigation scheme: arrows move, Return and Space activate, Esc backs out, everywhere the controller already worked (carousel, menu lists, prompts) plus the plain poster grid. `active` mirrors each caller's existing controller gate rather than being a second, parallel notion of "who has input". Without that, a launcher sitting under an open screen would keep eating key presses and navigate behind it — the same defect the pad gate exists to prevent. Esc returns `.ignored` when a screen has no back action, so it still reaches the `.cancelAction` shortcut that closes a macOS sheet. The plain grid needed real arithmetic rather than a flat index. It renders up to TWO `LazyVGrid` sections (launchers above titles), so a flat index steps by the wrong amount at the boundary whenever the first section's last row is partial — up from the titles' first row lands mid-launcher-row instead of above. `LibraryGridNav` moves within a section and hands off at its edges preserving the column, clamping into partial rows. It lives in PunktfunkKit because it is edge-case arithmetic and that is the target tests can reach; 12 cases cover the partial row, the hand-off, a stale cursor, an empty grid and a zero column count. The column count comes from the grid's MEASURED width run through `.adaptive`'s own fitting rule, so up/down move exactly one visual row instead of a guess that drifts with window size. Measured via a background GeometryReader — a sibling inside a ScrollView would claim the whole viewport. The grid cursor starts nil and only appears on the first arrow press, so a touch user is never shown a selection they didn't ask for. tvOS is excluded throughout: its focus engine already routes hardware arrows, and these screens hand it navigation authority deliberately. 17 PunktfunkKit tests pass; macOS + tvOS typecheck; launcher and settings verified rendering and navigating in the iPad Pro 13" simulator.
80 lines
3.6 KiB
Swift
80 lines
3.6 KiB
Swift
// Hardware-keyboard navigation for the gamepad UI (iOS/iPadOS/macOS): arrows move, Return/Space
|
|
// activate, Esc backs out.
|
|
//
|
|
// Asked for by a field user on an iPad ("select games with keyboard arrows, enter to launch"). An
|
|
// iPad on a Magic Keyboard and a couch Mac are the same situation the console layout was built
|
|
// for — a screen driven from a distance with a fixed set of directional inputs — and the whole
|
|
// navigation model (a cursor, a confirm, a back) already exists here for the controller. A
|
|
// keyboard is just a third input onto it, alongside the pad poll and touch.
|
|
//
|
|
// tvOS is excluded: the focus engine already routes hardware-keyboard arrows into focus moves
|
|
// there, and these screens hand it navigation authority on purpose.
|
|
//
|
|
// The view must be FOCUSED to receive key presses, so this takes focus on appear. That is safe on
|
|
// exactly these screens because the gamepad UI has no system text fields to steal it from —
|
|
// GamepadKeyboard is a custom grid of keycaps, not a `TextField`.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
#if os(iOS) || os(macOS)
|
|
|
|
extension View {
|
|
/// Route arrows / Return / Esc into the same handlers the controller poll drives.
|
|
///
|
|
/// `active` mirrors the caller's `isActive` controller gate: a screen that has handed the pad
|
|
/// to something on top must not keep eating key presses either, or a covered launcher
|
|
/// navigates behind the screen in front of it.
|
|
func gamepadKeyNavigation(
|
|
active: Bool = true,
|
|
onMove: @escaping (GamepadMenuInput.Direction) -> Void,
|
|
onConfirm: @escaping () -> Void,
|
|
onBack: (() -> Void)? = nil
|
|
) -> some View {
|
|
modifier(GamepadKeyNav(active: active, onMove: onMove, onConfirm: onConfirm, onBack: onBack))
|
|
}
|
|
}
|
|
|
|
private struct GamepadKeyNav: ViewModifier {
|
|
let active: Bool
|
|
let onMove: (GamepadMenuInput.Direction) -> Void
|
|
let onConfirm: () -> Void
|
|
let onBack: (() -> Void)?
|
|
|
|
@FocusState private var focused: Bool
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.focusable(active)
|
|
// No focus ring: these screens draw their own cursor (the centred card, the focused
|
|
// row), and a system ring around the whole scroll view on top of it reads as a bug.
|
|
.focusEffectDisabled()
|
|
.focused($focused)
|
|
// Claim focus on appear, and re-claim it whenever this screen becomes the active one
|
|
// again — a pushed screen popping off leaves the one underneath unfocused.
|
|
.onAppear { focused = active }
|
|
.onChange(of: active) { _, nowActive in
|
|
if nowActive { focused = true }
|
|
}
|
|
.onKeyPress(.upArrow) { handle { onMove(.up) } }
|
|
.onKeyPress(.downArrow) { handle { onMove(.down) } }
|
|
.onKeyPress(.leftArrow) { handle { onMove(.left) } }
|
|
.onKeyPress(.rightArrow) { handle { onMove(.right) } }
|
|
.onKeyPress(.return) { handle(onConfirm) }
|
|
.onKeyPress(.space) { handle(onConfirm) }
|
|
.onKeyPress(.escape) {
|
|
guard let onBack else { return .ignored }
|
|
return handle(onBack)
|
|
}
|
|
}
|
|
|
|
/// Run a handler only while this screen owns input, and report back whether the press was
|
|
/// consumed. `.ignored` matters: an unhandled Esc still has to reach the `.cancelAction`
|
|
/// shortcut that closes a macOS sheet (see GamepadAddHostView's hidden Cancel button).
|
|
private func handle(_ action: () -> Void) -> KeyPress.Result {
|
|
guard active else { return .ignored }
|
|
action()
|
|
return .handled
|
|
}
|
|
}
|
|
#endif
|