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.
This commit is contained in:
@@ -191,6 +191,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: 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()
|
||||
|
||||
@@ -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
|
||||
@@ -119,6 +119,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: 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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user