From bac63059a9dfc9e593da55f1475c202b159e817c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 08:48:49 +0200 Subject: [PATCH] feat(apple): drive the console UI from a hardware keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Home/GamepadCarousel.swift | 10 ++ .../PunktfunkClient/Home/GamepadKeyNav.swift | 79 +++++++++++++ .../Home/GamepadMenuList.swift | 16 +++ .../PunktfunkClient/Home/GamepadPrompt.swift | 11 ++ .../PunktfunkClient/Home/LibraryView.swift | 109 ++++++++++++++++-- .../PunktfunkKit/Gamepad/LibraryGridNav.swift | 74 ++++++++++++ .../LibraryGridNavTests.swift | 93 +++++++++++++++ 7 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Home/GamepadKeyNav.swift create mode 100644 clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridNav.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/LibraryGridNavTests.swift diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 2262cb7c..769d88e6 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -191,6 +191,16 @@ struct GamepadCarousel: View where Item.ID: Hash .sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick) + #if os(iOS) || os(macOS) + // A hardware keyboard drives the same cursor as the pad — arrows step, Return activates, + // Esc backs out (iPad on a Magic Keyboard, couch Mac). tvOS routes arrows through the + // focus engine instead, which owns navigation there. + .gamepadKeyNavigation( + active: isActive, + onMove: { move($0) }, + onConfirm: { activate() }, + onBack: onBack) + #endif .onAppear { reconcile() wire() diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyNav.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyNav.swift new file mode 100644 index 00000000..80a69b4b --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyNav.swift @@ -0,0 +1,79 @@ +// 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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift index e156eb40..449a528b 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift @@ -119,6 +119,22 @@ struct GamepadMenuList: View where Item.ID: Hasha .sensoryFeedback(.selection, trigger: adjustTick) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick) + #if os(iOS) || os(macOS) + // Hardware keyboard: up/down step the focus bar, left/right adjust the focused row's + // value (exactly what the stick does), Return activates, Esc backs out. + .gamepadKeyNavigation( + active: isActive, + onMove: { direction in + switch direction { + case .up: step(by: -1) + case .down: step(by: 1) + case .left: adjust(by: -1) + case .right: adjust(by: 1) + } + }, + onConfirm: { activate() }, + onBack: onBack) + #endif .onAppear { reconcile() wire() diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift index b54435b8..7ee270ed 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift @@ -80,6 +80,17 @@ struct GamepadPromptView: View { .sensoryFeedback(.selection, trigger: cursor) .sensoryFeedback(.impact(weight: .medium), trigger: activateTick) .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick) + // A prompt is exactly where a keyboard user gets stuck, so it takes arrows/Return/Esc too. + .gamepadKeyNavigation( + onMove: { direction in + switch direction { + case .up: step(by: -1) + case .down: step(by: 1) + case .left, .right: break + } + }, + onConfirm: { activate() }, + onBack: { back() }) .onAppear { cursor = prompt.actions.firstIndex(where: \.isCancel) ?? max(prompt.actions.count - 1, 0) wire() diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index dd511fd3..6e833a1b 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -27,6 +27,13 @@ struct LibraryView: View { /// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across /// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear. @State private var artLoader: LibraryArtLoader? + #if os(iOS) || os(macOS) + /// The plain grid's hardware-keyboard cursor (a game id), and the grid width the column count + /// is derived from. nil until the first arrow press, so a touch user never sees a selection + /// they didn't ask for. + @State private var keyCursor: String? + @State private var gridWidth: CGFloat = 0 + #endif #if os(iOS) || os(macOS) || os(tvOS) // Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the // setting off) every platform keeps the plain-grid presentation of this same view. @@ -120,34 +127,103 @@ struct LibraryView: View { let launchers = games.filter(\.isLauncher) let titles = games.filter { !$0.isLauncher } let both = !launchers.isEmpty && !titles.isEmpty - return ScrollView { - VStack(alignment: .leading, spacing: 18) { - if !launchers.isEmpty { - if both { sectionHeader("Launchers") } - tiles(launchers) + return ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if !launchers.isEmpty { + if both { sectionHeader("Launchers") } + tiles(launchers) + } + if !titles.isEmpty { + if both { sectionHeader("Games") } + tiles(titles) + } } - if !titles.isEmpty { - if both { sectionHeader("Games") } - tiles(titles) + .padding() + #if os(iOS) || os(macOS) + // The grid's own width, reported without affecting layout — a GeometryReader + // SIBLING inside a ScrollView would claim the whole viewport. It's what tells the + // keyboard cursor how many columns `.adaptive` actually produced, so it is only + // measured where that cursor exists. + .background { + GeometryReader { geo in + Color.clear + .onAppear { gridWidth = geo.size.width } + .onChange(of: geo.size.width) { _, w in gridWidth = w } + } } + #endif } - .padding() + #if os(iOS) || os(macOS) + // Hardware keyboard: arrows pick a title, Return launches it — a field ask from an + // iPad user on a Magic Keyboard. The gamepad UI's coverflow has had this via the + // controller all along; this is the same thing for the plain grid, which is what an + // iPad with a keyboard and NO pad actually sees. + .gamepadKeyNavigation( + active: onLaunch != nil, + onMove: { direction in + guard let next = gridNav(launchers: launchers, titles: titles) + .move(from: keyCursor, direction) else { return } + keyCursor = next + withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) } + }, + onConfirm: { + guard let onLaunch, let id = keyCursor else { return } + onLaunch(id) + }) + #endif } } + #if os(iOS) || os(macOS) + /// The keyboard cursor's model over the two grid sections. Rebuilt per press from the live + /// sections so it can never point into a stale list. + private func gridNav(launchers: [GameEntry], titles: [GameEntry]) -> LibraryGridNav { + LibraryGridNav( + sections: [launchers, titles].filter { !$0.isEmpty }.map { $0.map(\.id) }, + columns: columnCount) + } + + /// How many columns `.adaptive(minimum:spacing:)` fits into the measured width — the same + /// arithmetic the layout does, so up/down move exactly one visual row rather than a guess. + /// Falls back to one column before the first measurement lands. + private var columnCount: Int { + let minimum: CGFloat = 130 // matches `columns` below on iOS/macOS + let spacing: CGFloat = 18 + // The VStack's `.padding()` is inside the measured width, so take it back off. + let usable = gridWidth - 32 + guard usable > 0 else { return 1 } + return max(1, Int((usable + spacing) / (minimum + spacing))) + } + #endif + private func tiles(_ entries: [GameEntry]) -> some View { LazyVGrid(columns: columns, spacing: 18) { ForEach(entries) { game in if let onLaunch { - Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) } - .buttonStyle(.plain) + Button { onLaunch(game.id) } label: { + GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game)) + } + .buttonStyle(.plain) + .id(game.id) } else { - GameCard(game: game, artLoader: artLoader) + GameCard(game: game, artLoader: artLoader, selected: isKeyCursor(game)) + .id(game.id) } } } } + /// Whether the keyboard cursor is on this tile (always false where there is no keyboard + /// navigation to have moved it). + private func isKeyCursor(_ game: GameEntry) -> Bool { + #if os(iOS) || os(macOS) + keyCursor == game.id + #else + false + #endif + } + private func sectionHeader(_ text: String) -> some View { Text(text) .font(.geist(12, .semibold, relativeTo: .caption)) @@ -264,6 +340,9 @@ private struct LibraryBackCatcher: View { private struct GameCard: View { let game: GameEntry let artLoader: LibraryArtLoader? + /// The hardware-keyboard cursor is on this tile — drawn as an accent ring, since the plain + /// grid has no other way to say "Return launches THIS one". + var selected = false var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -271,6 +350,12 @@ private struct GameCard: View { .aspectRatio(2.0 / 3.0, contentMode: .fit) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + if selected { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(.tint, lineWidth: 3) + } + } .overlay(alignment: .topLeading) { StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher) } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridNav.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridNav.swift new file mode 100644 index 00000000..9b985121 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridNav.swift @@ -0,0 +1,74 @@ +// Where the arrow keys go in the library's plain poster grid (LibraryView's touch layout on +// iOS/iPadOS/macOS) — the model behind "select games with keyboard arrows, enter to launch". +// +// The grid is up to TWO sections (launcher entries above titles), each rendered as its own +// `LazyVGrid`. A single flat index across both would step by the wrong amount at the boundary +// whenever the first section's last row is partial — up from the second section's first row would +// land in the middle of the first section rather than on the row above. So moves happen WITHIN a +// section, with an explicit hand-off at its edges that preserves the column. +// +// Lives in PunktfunkKit rather than beside the view because this is arithmetic with edge cases — +// partial rows, section hand-offs, empty sections — and PunktfunkKit is the target the tests can +// reach (the app is an executable target). Pure values in, pure value out: no SwiftUI. + +import Foundation + +public struct LibraryGridNav { + /// Game ids per RENDERED section, in display order. Callers drop empty sections before + /// constructing this, so `sections` never contains one. + public let sections: [[String]] + /// How many columns the grid actually laid out — the caller derives it from the measured + /// width using `.adaptive`'s own fitting rule, so a vertical move is exactly one visual row. + public let columns: Int + + public init(sections: [[String]], columns: Int) { + self.sections = sections + // A zero or negative count would divide by zero below; one column is the degenerate grid. + self.columns = max(1, columns) + } + + /// The id `direction` leads to from `current`, or nil when there is nowhere to go (so the + /// caller leaves the cursor where it is). A nil `current` — nothing selected yet — lands on + /// the very first tile, so the first arrow press always produces a visible cursor rather than + /// appearing to do nothing. + public func move(from current: String?, _ direction: GamepadMenuInput.Direction) -> String? { + guard !sections.isEmpty else { return nil } + guard let (s, i) = locate(current) else { return sections[0].first } + switch direction { + case .left: + if i > 0 { return sections[s][i - 1] } + return s > 0 ? sections[s - 1].last : nil + case .right: + if i + 1 < sections[s].count { return sections[s][i + 1] } + return s + 1 < sections.count ? sections[s + 1].first : nil + case .up: + if i >= columns { return sections[s][i - columns] } + // Off the top of this section: the section above, same column, its LAST row — + // clamped, because that row may be partial. + guard s > 0 else { return nil } + let above = sections[s - 1] + let lastRowStart = ((above.count - 1) / columns) * columns + return above[min(lastRowStart + (i % columns), above.count - 1)] + case .down: + if i + columns < sections[s].count { return sections[s][i + columns] } + // Off the bottom: the section below, same column, its first row. + if s + 1 < sections.count { + let below = sections[s + 1] + return below[min(i % columns, below.count - 1)] + } + // Nothing below. A press from a full row above the last (partial) one still settles + // on the final tile rather than refusing — the row IS down from here, just short. + let lastRowStart = ((sections[s].count - 1) / columns) * columns + return i < lastRowStart ? sections[s].last : nil + } + } + + /// (section, index within it) for an id, or nil when it isn't in the grid any more. + private func locate(_ id: String?) -> (Int, Int)? { + guard let id else { return nil } + for (s, section) in sections.enumerated() { + if let i = section.firstIndex(of: id) { return (s, i) } + } + return nil + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/LibraryGridNavTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryGridNavTests.swift new file mode 100644 index 00000000..cfbb96d0 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/LibraryGridNavTests.swift @@ -0,0 +1,93 @@ +// Arrow-key navigation over the library's two-section poster grid. The cases that matter are the +// ones a flat index gets wrong: a PARTIAL last row, and the hand-off between the launcher section +// and the titles below it. + +import XCTest +@testable import PunktfunkKit + +final class LibraryGridNavTests: XCTestCase { + /// Two sections, 3 columns: + /// launchers L0 L1 (one partial row) + /// titles T0 T1 T2 + /// T3 T4 + private let nav = LibraryGridNav( + sections: [["L0", "L1"], ["T0", "T1", "T2", "T3", "T4"]], columns: 3) + + func testFirstPressSelectsTheFirstTile() { + XCTAssertEqual(nav.move(from: nil, .right), "L0") + XCTAssertEqual(nav.move(from: nil, .down), "L0") + } + + func testHorizontalMovesWithinARow() { + XCTAssertEqual(nav.move(from: "T0", .right), "T1") + XCTAssertEqual(nav.move(from: "T1", .left), "T0") + } + + /// Left/right run through the whole grid in display order, crossing the section boundary — + /// the launchers are simply the first tiles. + func testHorizontalCrossesTheSectionBoundary() { + XCTAssertEqual(nav.move(from: "L1", .right), "T0") + XCTAssertEqual(nav.move(from: "T0", .left), "L1") + } + + func testVerticalMovesOneRowWithinASection() { + XCTAssertEqual(nav.move(from: "T0", .down), "T3") + XCTAssertEqual(nav.move(from: "T3", .up), "T0") + } + + /// Down from the launcher row lands in the titles' first row at the SAME column — this is the + /// move a flat index gets wrong, because the launcher row is partial. + func testDownFromLaunchersKeepsTheColumn() { + XCTAssertEqual(nav.move(from: "L0", .down), "T0") + XCTAssertEqual(nav.move(from: "L1", .down), "T1") + } + + /// Up out of the titles' first row lands in the launcher row, clamped to what is actually + /// there: column 2 has no launcher above it, so it settles on the last one rather than + /// running off the end. + func testUpIntoAPartialLauncherRowClamps() { + XCTAssertEqual(nav.move(from: "T0", .up), "L0") + XCTAssertEqual(nav.move(from: "T1", .up), "L1") + XCTAssertEqual(nav.move(from: "T2", .up), "L1") + } + + /// Down from a full row into a SHORTER last row still moves — landing on the final tile — but + /// there is nothing below the last row itself. + func testDownIntoAPartialLastRow() { + XCTAssertEqual(nav.move(from: "T2", .down), "T4") // column 2 has no T5 + XCTAssertNil(nav.move(from: "T4", .down)) + } + + func testEdgesRefuseRatherThanWrap() { + XCTAssertNil(nav.move(from: "L0", .left)) + XCTAssertNil(nav.move(from: "L0", .up)) + XCTAssertNil(nav.move(from: "T4", .right)) + } + + /// A library with no launcher entries renders ONE section — the common case, and it must + /// behave like a plain grid. + func testSingleSectionGrid() { + let single = LibraryGridNav(sections: [["A", "B", "C", "D"]], columns: 2) + XCTAssertEqual(single.move(from: "A", .down), "C") + XCTAssertEqual(single.move(from: "D", .up), "B") + XCTAssertNil(single.move(from: "A", .up)) + } + + /// An id that is no longer in the grid (the list reloaded under the cursor) re-seeds rather + /// than returning nil forever. + func testStaleCursorReseeds() { + XCTAssertEqual(nav.move(from: "gone", .down), "L0") + } + + func testEmptyGridHasNowhereToGo() { + let empty = LibraryGridNav(sections: [], columns: 3) + XCTAssertNil(empty.move(from: nil, .down)) + } + + /// A degenerate column count must not divide by zero. + func testZeroColumnsIsClampedToOne() { + let single = LibraryGridNav(sections: [["A", "B"]], columns: 0) + XCTAssertEqual(single.columns, 1) + XCTAssertEqual(single.move(from: "A", .down), "B") + } +}