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:
2026-09-20 11:26:05 -04:00
parent a13a797bf5
commit d68f1d0d1d
8 changed files with 371 additions and 12 deletions

View File

@@ -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,