Opening an item reads it, and the page's keys (#11)
selectEntry in the page calls markRead, so picking a row reads it. Natively an item could be opened, read and left, and it stayed bold. With it comes the detail that would have been missed by guessing: on the Unread tab the item you were reading is dropped when you move on to the next one, not whenever a refresh next comes along, so nothing vanishes from under the pointer mid-click. A flag that does not save rolls back and says so, where the page toasts. The keys are Feedly's set, from player.ts: j n and k p, shift J and K, o m s v, shift A, r, [, space, the arrows, g then a d p l or s, and ? for the list of them. The pair window is the page's second and a half, and p and s mean different things with and without a g in front, which they do there too. They are UIKeyCommand rather than SwiftUI's onKeyPress, which is iOS 17, and they hang off the hosting controller rather than the controller that owns it: SwiftUI's views hold first responder, so the chain starts inside that hierarchy and an override further up is never consulted. What is tested is the map, in KeysTests -- that p is Previous alone and Popular after g, that every mapped key is registered, that the window is 1.5s -- because a wrong letter there loses a shortcut silently. What is not tested is whether a press arrives at all: the simulator drops key events unless something is focused, and running the same tests against Catalyst, where the keyboard is the point, needs the runner to have accessibility permission it does not have here. That test is skipped with the reason written in it rather than deleted or left red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
26
ios/Sources/KeyHostingController.swift
Normal file
26
ios/Sources/KeyHostingController.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// A hosting controller that answers to the keyboard.
|
||||||
|
///
|
||||||
|
/// The keys belong here rather than on the controller that owns this one: SwiftUI's views take
|
||||||
|
/// first responder, so the responder chain starts inside this hierarchy, and an override on a
|
||||||
|
/// parent further up was never consulted at all.
|
||||||
|
final class KeyHostingController: UIHostingController<AnyView> {
|
||||||
|
var onKey: ((String) -> Void)?
|
||||||
|
|
||||||
|
override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
becomeFirstResponder()
|
||||||
|
}
|
||||||
|
|
||||||
|
override var keyCommands: [UIKeyCommand]? { Keys.commands(#selector(key(_:))) }
|
||||||
|
|
||||||
|
@objc private func key(_ command: UIKeyCommand) {
|
||||||
|
guard let input = command.input else { return }
|
||||||
|
onKey?(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
79
ios/Sources/Keys.swift
Normal file
79
ios/Sources/Keys.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// The page's keyboard, natively.
|
||||||
|
///
|
||||||
|
/// Feedly's set, which is what ipx uses: a letter to move through items or feeds, g and a letter
|
||||||
|
/// to go somewhere, ? to list them. None fire with Ctrl, Alt or Command held, so the system's own
|
||||||
|
/// shortcuts still work.
|
||||||
|
///
|
||||||
|
/// UIKeyCommand rather than SwiftUI's onKeyPress, which is iOS 17 and this still runs on 16.
|
||||||
|
enum Keys {
|
||||||
|
/// What a key asks for. The controller decides how to do it.
|
||||||
|
enum Action: Equatable {
|
||||||
|
case nextItem, previousItem
|
||||||
|
case nextFeed, previousFeed
|
||||||
|
case play, toggleRead, togglePin, openOriginal
|
||||||
|
case readAll, refresh, toggleSidebar, shortcuts
|
||||||
|
case playPause, back15, forward30
|
||||||
|
case go(Go)
|
||||||
|
case startPair
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Go: Equatable { case all, directory, popular, listening, settings }
|
||||||
|
|
||||||
|
/// Single keys, and the ones that only mean something after g.
|
||||||
|
static let single: [String: Action] = [
|
||||||
|
"j": .nextItem, "n": .nextItem, "k": .previousItem, "p": .previousItem,
|
||||||
|
"J": .nextFeed, "K": .previousFeed,
|
||||||
|
"o": .play, "m": .toggleRead, "s": .togglePin, "v": .openOriginal,
|
||||||
|
"A": .readAll, "r": .refresh, "[": .toggleSidebar, "?": .shortcuts,
|
||||||
|
"g": .startPair,
|
||||||
|
]
|
||||||
|
|
||||||
|
static let paired: [String: Go] = [
|
||||||
|
"a": .all, "d": .directory, "p": .popular, "l": .listening, "s": .settings,
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Every key the app answers to, for UIResponder.keyCommands. Space and the arrows are
|
||||||
|
/// listed separately because their input is not a letter.
|
||||||
|
static func commands(_ selector: Selector) -> [UIKeyCommand] {
|
||||||
|
var all = single.keys.map { UIKeyCommand(input: $0, modifierFlags: [], action: selector) }
|
||||||
|
all.append(UIKeyCommand(input: " ", modifierFlags: [], action: selector))
|
||||||
|
all.append(UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: selector))
|
||||||
|
all.append(UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: selector))
|
||||||
|
// The second key of a g pair: only these, and only just after a g.
|
||||||
|
for key in paired.keys where single[key] == nil {
|
||||||
|
all.append(UIKeyCommand(input: key, modifierFlags: [], action: selector))
|
||||||
|
}
|
||||||
|
for command in all { command.wantsPriorityOverSystemBehavior = true }
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long after g the second key still counts, matching the page.
|
||||||
|
static let pairWindow: TimeInterval = 1.5
|
||||||
|
|
||||||
|
/// What a key press means, given whether a g is still warm.
|
||||||
|
static func action(for input: String, afterG: Bool) -> Action? {
|
||||||
|
if afterG, let go = paired[input] { return .go(go) }
|
||||||
|
switch input {
|
||||||
|
case " ": return .playPause
|
||||||
|
case UIKeyCommand.inputLeftArrow: return .back15
|
||||||
|
case UIKeyCommand.inputRightArrow: return .forward30
|
||||||
|
default: return single[input]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list ? shows, grouped as the page groups it.
|
||||||
|
static let listing: [(String, [(String, String)])] = [
|
||||||
|
("Go to", [("g a", "All Subscriptions"), ("g d", "Directory"), ("g p", "Popular"),
|
||||||
|
("g l", "Currently Listening"), ("g s", "Settings"),
|
||||||
|
("⇧ J", "Next feed"), ("⇧ K", "Previous feed"),
|
||||||
|
("r", "Refresh"), ("[", "Show or hide the feed list")]),
|
||||||
|
("Items", [("j or n", "Next item"), ("k or p", "Previous item"),
|
||||||
|
("⇧ A", "Mark all read")]),
|
||||||
|
("The selected item", [("o", "Play it"), ("m", "Mark it read or unread"),
|
||||||
|
("s", "Pin it, or unpin it"), ("v", "Open the original")]),
|
||||||
|
("The player", [("Space", "Play or pause"), ("← →", "Back 15 seconds, forward 30")]),
|
||||||
|
("Anywhere", [("?", "This list")]),
|
||||||
|
]
|
||||||
|
}
|
||||||
34
ios/Sources/KeysView.swift
Normal file
34
ios/Sources/KeysView.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// What ? shows: every key, grouped as the page groups them.
|
||||||
|
struct KeysView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(Keys.listing, id: \.0) { group in
|
||||||
|
Section(group.0) {
|
||||||
|
ForEach(group.1, id: \.0) { key, what in
|
||||||
|
HStack {
|
||||||
|
Text(key)
|
||||||
|
.font(.system(.footnote, design: .monospaced))
|
||||||
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
|
.background(Glass.line.opacity(0.5),
|
||||||
|
in: RoundedRectangle(cornerRadius: 5))
|
||||||
|
.frame(minWidth: 62, alignment: .leading)
|
||||||
|
Text(what).foregroundStyle(Glass.dim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Keyboard shortcuts")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,10 @@ final class LibraryStore: ObservableObject {
|
|||||||
@Published var sort: IPX.Sort = .published { didSet { reload() } }
|
@Published var sort: IPX.Sort = .published { didSet { reload() } }
|
||||||
@Published var direction: IPX.Direction = .desc { didSet { reload() } }
|
@Published var direction: IPX.Direction = .desc { didSet { reload() } }
|
||||||
@Published var search = "" { didSet { searchChanged() } }
|
@Published var search = "" { didSet { searchChanged() } }
|
||||||
@Published var selected: IPX.Entry.ID?
|
@Published var selected: IPX.Entry.ID? { didSet { if selected != oldValue { opened() } } }
|
||||||
|
/// Something to say when a write does not save. The page toasts; this is the same thing
|
||||||
|
/// waiting for a view to show it.
|
||||||
|
@Published var problem: String?
|
||||||
|
|
||||||
private let api: API
|
private let api: API
|
||||||
private var searchTask: Task<Void, Never>?
|
private var searchTask: Task<Void, Never>?
|
||||||
@@ -130,6 +133,49 @@ final class LibraryStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - opening an item
|
||||||
|
|
||||||
|
/// Opening an item is reading it, as it is in the page.
|
||||||
|
///
|
||||||
|
/// And on the Unread tab the one you were reading goes when you move on to the next, rather
|
||||||
|
/// than whenever a refresh next happens to come along -- which left a handful of read items
|
||||||
|
/// sitting in the list for a while. It goes on the way out, so nothing disappears from under
|
||||||
|
/// the pointer as it is being clicked.
|
||||||
|
private func opened() {
|
||||||
|
guard let id = selected, let entry = entries.first(where: { $0.id == id }) else { return }
|
||||||
|
|
||||||
|
if filter == .unread, let leaving = previous, leaving != id,
|
||||||
|
let was = entries.first(where: { $0.id == leaving }), was.read {
|
||||||
|
entries.removeAll { $0.id == leaving }
|
||||||
|
total = max(0, total - 1)
|
||||||
|
}
|
||||||
|
previous = id
|
||||||
|
|
||||||
|
if !entry.read { setRead(entry, true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var previous: IPX.Entry.ID?
|
||||||
|
|
||||||
|
/// Move by one, and open it. Nothing to do at either end of the list.
|
||||||
|
func step(_ by: Int) {
|
||||||
|
guard !entries.isEmpty else { return }
|
||||||
|
let at = entries.firstIndex { $0.id == selected } ?? -1
|
||||||
|
let next = at < 0 ? 0 : min(entries.count - 1, max(0, at + by))
|
||||||
|
selected = entries[next].id
|
||||||
|
// Near the end, fetch the next page so stepping does not stop at fifty.
|
||||||
|
if next >= entries.count - 3 { loadMore() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feed after or before this one, in the order the sidebar shows them.
|
||||||
|
func stepFeed(_ by: Int, within order: [LibraryStore.Place]) {
|
||||||
|
guard let at = order.firstIndex(of: place) else { return place = order.first ?? .all }
|
||||||
|
let next = at + by
|
||||||
|
guard order.indices.contains(next) else { return }
|
||||||
|
place = order[next]
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedEntryLive: IPX.Entry? { entries.first { $0.id == selected } }
|
||||||
|
|
||||||
// MARK: - writing
|
// MARK: - writing
|
||||||
|
|
||||||
/// Read and pinned are set here first and sent after. The page does the same, for the same
|
/// Read and pinned are set here first and sent after. The page does the same, for the same
|
||||||
@@ -138,8 +184,13 @@ final class LibraryStore: ObservableObject {
|
|||||||
func setRead(_ entry: IPX.Entry, _ read: Bool) {
|
func setRead(_ entry: IPX.Entry, _ read: Bool) {
|
||||||
replace(entry.id) { $0.with(read: read) }
|
replace(entry.id) { $0.with(read: read) }
|
||||||
Task {
|
Task {
|
||||||
do { try await api.setRead(read, feed: entry.feedId, guid: entry.guid) }
|
do {
|
||||||
catch { replace(entry.id) { $0.with(read: !read) } }
|
try await api.setRead(read, feed: entry.feedId, guid: entry.guid)
|
||||||
|
await refreshFeeds()
|
||||||
|
} catch {
|
||||||
|
replace(entry.id) { $0.with(read: !read) }
|
||||||
|
problem = "That did not save: \((error as? LocalizedError)?.errorDescription ?? "\(error)")"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +198,10 @@ final class LibraryStore: ObservableObject {
|
|||||||
replace(entry.id) { $0.with(flagged: pinned) }
|
replace(entry.id) { $0.with(flagged: pinned) }
|
||||||
Task {
|
Task {
|
||||||
do { try await api.setPinned(pinned, feed: entry.feedId, guid: entry.guid) }
|
do { try await api.setPinned(pinned, feed: entry.feedId, guid: entry.guid) }
|
||||||
catch { replace(entry.id) { $0.with(flagged: !pinned) } }
|
catch {
|
||||||
|
replace(entry.id) { $0.with(flagged: !pinned) }
|
||||||
|
problem = "That did not save: \((error as? LocalizedError)?.errorDescription ?? "\(error)")"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ struct LibraryView: View {
|
|||||||
/// The file is the one asked for, or the item's first playable one.
|
/// The file is the one asked for, or the item's first playable one.
|
||||||
var play: (IPX.Entry, IPX.Enclosure?) -> Void
|
var play: (IPX.Entry, IPX.Enclosure?) -> Void
|
||||||
var openPage: (WebViewController.Screen) -> Void
|
var openPage: (WebViewController.Screen) -> Void
|
||||||
|
@ObservedObject var keys: RootViewController.Flag
|
||||||
|
@ObservedObject var sidebar: RootViewController.Flag
|
||||||
|
|
||||||
/// Everything that lives in the page, named rather than hidden behind one button: it should
|
/// Everything that lives in the page, named rather than hidden behind one button: it should
|
||||||
/// be as easy to reach Settings here as it is in a browser.
|
/// be as easy to reach Settings here as it is in a browser.
|
||||||
@@ -33,17 +35,39 @@ struct LibraryView: View {
|
|||||||
.help("Settings, the directory, OPML")
|
.help("Settings, the directory, OPML")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Environment(\.horizontalSizeClass) private var width
|
||||||
|
private var wide: Bool { width != .compact }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView(columnVisibility: Binding(
|
||||||
|
get: { sidebar.on ? .detailOnly : .all },
|
||||||
|
set: { sidebar.on = $0 == .detailOnly })) {
|
||||||
FeedListView(store: store)
|
FeedListView(store: store)
|
||||||
.background(Glass.Wash())
|
|
||||||
} detail: {
|
} detail: {
|
||||||
ItemListView(store: store, playback: playback, play: play)
|
// Wide enough for the page's own three-pane arrangement, or the phone's one-at-a-
|
||||||
.toolbar {
|
// time. The page draws the same line at 820px and hides its columns below it.
|
||||||
ToolbarItem(placement: .primaryAction) { pageMenu }
|
Group {
|
||||||
|
if wide {
|
||||||
|
ContentPaneView(store: store, playback: playback, play: play)
|
||||||
|
} else {
|
||||||
|
ItemListView(store: store, playback: playback, play: play)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.toolbar { ToolbarItem(placement: .primaryAction) { pageMenu } }
|
||||||
}
|
}
|
||||||
|
// The wash belongs to the window, once. A copy behind each pane met at the split and
|
||||||
|
// the join showed as a seam straight down the middle.
|
||||||
|
.background(Glass.Wash())
|
||||||
.tint(Glass.accent)
|
.tint(Glass.accent)
|
||||||
|
.navigationSplitViewColumnWidth(min: 200, ideal: 260)
|
||||||
|
.sheet(isPresented: $keys.on) { KeysView() }
|
||||||
|
.alert("That did not save",
|
||||||
|
isPresented: Binding(get: { store.problem != nil },
|
||||||
|
set: { if !$0 { store.problem = nil } })) {
|
||||||
|
Button("OK", role: .cancel) { store.problem = nil }
|
||||||
|
} message: {
|
||||||
|
Text(store.problem ?? "")
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
await store.refreshFeeds()
|
await store.refreshFeeds()
|
||||||
store.reload()
|
store.reload()
|
||||||
|
|||||||
@@ -14,7 +14,19 @@ final class RootViewController: UIViewController {
|
|||||||
private var cookies: CookieBridge!
|
private var cookies: CookieBridge!
|
||||||
private var playback: Playback!
|
private var playback: Playback!
|
||||||
private var page: WebViewController!
|
private var page: WebViewController!
|
||||||
private var host: UIHostingController<AnyView>!
|
private var host: KeyHostingController!
|
||||||
|
private let showingKeysFlag = Flag()
|
||||||
|
private let sidebarFlag = Flag()
|
||||||
|
|
||||||
|
/// Small boxes so a UIKit key handler can move a SwiftUI @Published value.
|
||||||
|
final class Flag: ObservableObject { @Published var on = false }
|
||||||
|
|
||||||
|
private var showingKeys: Bool {
|
||||||
|
get { showingKeysFlag.on } set { showingKeysFlag.on = newValue }
|
||||||
|
}
|
||||||
|
private var showingSidebar: Bool {
|
||||||
|
get { sidebarFlag.on } set { sidebarFlag.on = newValue }
|
||||||
|
}
|
||||||
private var watching: AnyCancellable?
|
private var watching: AnyCancellable?
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
@@ -34,10 +46,16 @@ final class RootViewController: UIViewController {
|
|||||||
store: store,
|
store: store,
|
||||||
playback: playback,
|
playback: playback,
|
||||||
play: { [weak self] entry, file in self?.play(entry, file) },
|
play: { [weak self] entry, file in self?.play(entry, file) },
|
||||||
openPage: { [weak self] screen in self?.showPage(screen) })
|
openPage: { [weak self] screen in self?.showPage(screen) },
|
||||||
|
keys: showingKeysFlag,
|
||||||
|
sidebar: sidebarFlag)
|
||||||
.environment(\.api, api)
|
.environment(\.api, api)
|
||||||
|
|
||||||
host = UIHostingController(rootView: AnyView(root))
|
// The keys hang off the hosting controller rather than this one. SwiftUI's views hold
|
||||||
|
// first responder, and a keyCommands override up here was simply never consulted -- it
|
||||||
|
// failed by doing nothing, which is the hardest kind of wrong to notice.
|
||||||
|
host = KeyHostingController(rootView: AnyView(root))
|
||||||
|
host.onKey = { [weak self] input in self?.key(input) }
|
||||||
addChild(host)
|
addChild(host)
|
||||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(host.view)
|
view.addSubview(host.view)
|
||||||
@@ -63,6 +81,55 @@ final class RootViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - the keyboard
|
||||||
|
|
||||||
|
/// When a g was pressed, so the next key can be read as the second half of a pair.
|
||||||
|
private var gAt: Date?
|
||||||
|
|
||||||
|
private func key(_ input: String) {
|
||||||
|
// A pair only counts within a second and a half of the g, as in the page.
|
||||||
|
let warm = gAt.map { Date().timeIntervalSince($0) < Keys.pairWindow } ?? false
|
||||||
|
gAt = nil
|
||||||
|
guard let action = Keys.action(for: input, afterG: warm) else { return }
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case .startPair: gAt = Date()
|
||||||
|
case .nextItem: store.step(1)
|
||||||
|
case .previousItem: store.step(-1)
|
||||||
|
case .nextFeed: store.stepFeed(1, within: places)
|
||||||
|
case .previousFeed: store.stepFeed(-1, within: places)
|
||||||
|
case .play: if let e = store.selectedEntryLive { play(e, nil) }
|
||||||
|
case .toggleRead: if let e = store.selectedEntryLive { store.setRead(e, !e.read) }
|
||||||
|
case .togglePin: if let e = store.selectedEntryLive { store.setPinned(e, !e.flagged) }
|
||||||
|
case .openOriginal:
|
||||||
|
if let link = store.selectedEntryLive?.link, let url = URL(string: link) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
case .readAll: store.readAll()
|
||||||
|
case .refresh: Task { await store.refreshFeeds(); store.reload() }
|
||||||
|
case .toggleSidebar: showingSidebar.toggle()
|
||||||
|
case .shortcuts: showingKeys = true
|
||||||
|
case .playPause: playback.playing ? playback.pause() : playback.play()
|
||||||
|
case .back15: playback.seek(to: playback.elapsed - 15)
|
||||||
|
case .forward30: playback.seek(to: playback.elapsed + 30)
|
||||||
|
case .go(let where_):
|
||||||
|
switch where_ {
|
||||||
|
case .all: store.place = .all
|
||||||
|
case .listening: store.place = .listening
|
||||||
|
case .directory: showPage(.directory)
|
||||||
|
case .popular: showPage(.popular)
|
||||||
|
case .settings: showPage(.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The feeds in the order the sidebar lists them, so J and K move the way the eye does.
|
||||||
|
private var places: [LibraryStore.Place] {
|
||||||
|
[.all, .listening] + store.feeds
|
||||||
|
.sorted { ($0.pinned ? 0 : 1, $0.name.lowercased()) < ($1.pinned ? 0 : 1, $1.name.lowercased()) }
|
||||||
|
.map { .feed($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Plays a row through the same Playback the lock screen and the car drive.
|
/// Plays a row through the same Playback the lock screen and the car drive.
|
||||||
private func play(_ entry: IPX.Entry, _ asked: IPX.Enclosure?) {
|
private func play(_ entry: IPX.Entry, _ asked: IPX.Enclosure?) {
|
||||||
guard let file = asked ?? entry.playable,
|
guard let file = asked ?? entry.playable,
|
||||||
|
|||||||
60
ios/Tests/KeysTests.swift
Normal file
60
ios/Tests/KeysTests.swift
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import iPodderX
|
||||||
|
|
||||||
|
/// The keyboard map. A wrong letter here loses a shortcut silently -- the key does nothing and
|
||||||
|
/// nothing says why -- so the map is worth checking even though driving the keys is not.
|
||||||
|
final class KeysTests: XCTestCase {
|
||||||
|
func testTheKeysAreThePagesKeys() {
|
||||||
|
// Straight from web/src/player.ts: j and n forward, k and p back.
|
||||||
|
XCTAssertEqual(Keys.action(for: "j", afterG: false), .nextItem)
|
||||||
|
XCTAssertEqual(Keys.action(for: "n", afterG: false), .nextItem)
|
||||||
|
XCTAssertEqual(Keys.action(for: "k", afterG: false), .previousItem)
|
||||||
|
XCTAssertEqual(Keys.action(for: "p", afterG: false), .previousItem)
|
||||||
|
XCTAssertEqual(Keys.action(for: "J", afterG: false), .nextFeed)
|
||||||
|
XCTAssertEqual(Keys.action(for: "K", afterG: false), .previousFeed)
|
||||||
|
XCTAssertEqual(Keys.action(for: "o", afterG: false), .play)
|
||||||
|
XCTAssertEqual(Keys.action(for: "m", afterG: false), .toggleRead)
|
||||||
|
XCTAssertEqual(Keys.action(for: "s", afterG: false), .togglePin)
|
||||||
|
XCTAssertEqual(Keys.action(for: "v", afterG: false), .openOriginal)
|
||||||
|
XCTAssertEqual(Keys.action(for: "A", afterG: false), .readAll)
|
||||||
|
XCTAssertEqual(Keys.action(for: "r", afterG: false), .refresh)
|
||||||
|
XCTAssertEqual(Keys.action(for: "[", afterG: false), .toggleSidebar)
|
||||||
|
XCTAssertEqual(Keys.action(for: "?", afterG: false), .shortcuts)
|
||||||
|
XCTAssertEqual(Keys.action(for: " ", afterG: false), .playPause)
|
||||||
|
XCTAssertEqual(Keys.action(for: UIKeyCommand.inputLeftArrow, afterG: false), .back15)
|
||||||
|
XCTAssertEqual(Keys.action(for: UIKeyCommand.inputRightArrow, afterG: false), .forward30)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The g pairs, and the two letters that mean different things depending on the g.
|
||||||
|
func testAPairMeansSomethingElse() {
|
||||||
|
XCTAssertEqual(Keys.action(for: "a", afterG: true), .go(.all))
|
||||||
|
XCTAssertEqual(Keys.action(for: "d", afterG: true), .go(.directory))
|
||||||
|
XCTAssertEqual(Keys.action(for: "l", afterG: true), .go(.listening))
|
||||||
|
|
||||||
|
// p is Previous item on its own and Popular after g; s is Pin, then Settings.
|
||||||
|
XCTAssertEqual(Keys.action(for: "p", afterG: false), .previousItem)
|
||||||
|
XCTAssertEqual(Keys.action(for: "p", afterG: true), .go(.popular))
|
||||||
|
XCTAssertEqual(Keys.action(for: "s", afterG: false), .togglePin)
|
||||||
|
XCTAssertEqual(Keys.action(for: "s", afterG: true), .go(.settings))
|
||||||
|
|
||||||
|
XCTAssertEqual(Keys.action(for: "g", afterG: false), .startPair)
|
||||||
|
// A letter that is no part of a pair still does its own thing after a g.
|
||||||
|
XCTAssertEqual(Keys.action(for: "j", afterG: true), .nextItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEveryKeyIsRegisteredAsACommand() {
|
||||||
|
let registered = Set(Keys.commands(#selector(getter: UIView.tag)).compactMap(\.input))
|
||||||
|
for key in Keys.single.keys {
|
||||||
|
XCTAssertTrue(registered.contains(key), "\(key) is mapped but never registered")
|
||||||
|
}
|
||||||
|
for key in Keys.paired.keys {
|
||||||
|
XCTAssertTrue(registered.contains(key), "\(key) is a pair but never registered")
|
||||||
|
}
|
||||||
|
XCTAssertTrue(registered.contains(" "), "space is not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window matches the page's: a second key counts for a second and a half after the g.
|
||||||
|
func testThePairWindowMatchesThePage() {
|
||||||
|
XCTAssertEqual(Keys.pairWindow, 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,3 +131,18 @@ final class PageScreenTests: XCTestCase {
|
|||||||
"the dialog is there but not the one expected")
|
"the dialog is there but not the one expected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The page's keys, which are most of why a list is worth using on a Mac.
|
||||||
|
///
|
||||||
|
/// These are skipped rather than deleted, and it is worth saying why. Driving them needs key
|
||||||
|
/// events to reach an app that is not focused on a text field, and the simulator drops those;
|
||||||
|
/// running the same tests against Mac Catalyst, where the keyboard is the point, needs the test
|
||||||
|
/// runner to have accessibility permission, which it does not have here. The mapping itself is
|
||||||
|
/// covered by KeysTests, which is where a typo would silently lose a shortcut. What is left
|
||||||
|
/// unproven is the wiring: whether the responder chain delivers a press at all.
|
||||||
|
final class KeyboardTests: XCTestCase {
|
||||||
|
func testTheKeysAreDrivenByHand() throws {
|
||||||
|
throw XCTSkip("key events need a focused app the simulator will not give, and the "
|
||||||
|
+ "Catalyst runner needs accessibility permission; press ? in the app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user