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:
@@ -14,7 +14,19 @@ final class RootViewController: UIViewController {
|
||||
private var cookies: CookieBridge!
|
||||
private var playback: Playback!
|
||||
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?
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user