diff --git a/ios/Sources/KeyHostingController.swift b/ios/Sources/KeyHostingController.swift new file mode 100644 index 0000000..abac1e7 --- /dev/null +++ b/ios/Sources/KeyHostingController.swift @@ -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 { + 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) + } +} + diff --git a/ios/Sources/Keys.swift b/ios/Sources/Keys.swift new file mode 100644 index 0000000..2bf6992 --- /dev/null +++ b/ios/Sources/Keys.swift @@ -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")]), + ] +} diff --git a/ios/Sources/KeysView.swift b/ios/Sources/KeysView.swift new file mode 100644 index 0000000..58e2458 --- /dev/null +++ b/ios/Sources/KeysView.swift @@ -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() } + } + } + } + } +} diff --git a/ios/Sources/LibraryStore.swift b/ios/Sources/LibraryStore.swift index dc1be25..50c40bb 100644 --- a/ios/Sources/LibraryStore.swift +++ b/ios/Sources/LibraryStore.swift @@ -36,7 +36,10 @@ final class LibraryStore: ObservableObject { @Published var sort: IPX.Sort = .published { didSet { reload() } } @Published var direction: IPX.Direction = .desc { didSet { reload() } } @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 var searchTask: Task? @@ -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 /// 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) { replace(entry.id) { $0.with(read: read) } Task { - do { try await api.setRead(read, feed: entry.feedId, guid: entry.guid) } - catch { replace(entry.id) { $0.with(read: !read) } } + do { + 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) } Task { 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)")" + } } } diff --git a/ios/Sources/LibraryView.swift b/ios/Sources/LibraryView.swift index 1a80dff..318167f 100644 --- a/ios/Sources/LibraryView.swift +++ b/ios/Sources/LibraryView.swift @@ -11,6 +11,8 @@ struct LibraryView: View { /// The file is the one asked for, or the item's first playable one. var play: (IPX.Entry, IPX.Enclosure?) -> 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 /// 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") } + @Environment(\.horizontalSizeClass) private var width + private var wide: Bool { width != .compact } + var body: some View { - NavigationSplitView { + NavigationSplitView(columnVisibility: Binding( + get: { sidebar.on ? .detailOnly : .all }, + set: { sidebar.on = $0 == .detailOnly })) { FeedListView(store: store) - .background(Glass.Wash()) } detail: { - ItemListView(store: store, playback: playback, play: play) - .toolbar { - ToolbarItem(placement: .primaryAction) { pageMenu } + // Wide enough for the page's own three-pane arrangement, or the phone's one-at-a- + // time. The page draws the same line at 820px and hides its columns below it. + 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) + .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 { await store.refreshFeeds() store.reload() diff --git a/ios/Sources/RootViewController.swift b/ios/Sources/RootViewController.swift index 71ea950..e692a2c 100644 --- a/ios/Sources/RootViewController.swift +++ b/ios/Sources/RootViewController.swift @@ -14,7 +14,19 @@ final class RootViewController: UIViewController { private var cookies: CookieBridge! private var playback: Playback! private var page: WebViewController! - private var host: UIHostingController! + 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? override func viewDidLoad() { @@ -34,10 +46,16 @@ final class RootViewController: UIViewController { store: store, playback: playback, 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) - 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) host.view.translatesAutoresizingMaskIntoConstraints = false 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. private func play(_ entry: IPX.Entry, _ asked: IPX.Enclosure?) { guard let file = asked ?? entry.playable, diff --git a/ios/Tests/KeysTests.swift b/ios/Tests/KeysTests.swift new file mode 100644 index 0000000..8659860 --- /dev/null +++ b/ios/Tests/KeysTests.swift @@ -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) + } +} diff --git a/ios/UITests/PlaybackTests.swift b/ios/UITests/PlaybackTests.swift index c47be64..cd92c34 100644 --- a/ios/UITests/PlaybackTests.swift +++ b/ios/UITests/PlaybackTests.swift @@ -131,3 +131,18 @@ final class PageScreenTests: XCTestCase { "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") + } +}