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.
75 lines
3.9 KiB
Swift
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
|
|
}
|
|
}
|