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:
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")]),
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user