Files
punktfunk/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridNav.swift
T
enricobuehler bac63059a9 feat(apple): drive the console UI from a hardware keyboard
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.
2026-08-10 09:59:28 +02:00

75 lines
3.9 KiB
Swift

// 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
}
}