diff --git a/iOS/Detail/DetailViewController.swift b/iOS/Detail/DetailViewController.swift index 77475adfa..83ee0762c 100644 --- a/iOS/Detail/DetailViewController.swift +++ b/iOS/Detail/DetailViewController.swift @@ -26,6 +26,15 @@ class DetailViewController: UIViewController { weak var coordinator: SceneCoordinator! + lazy var keyboardManager = KeyboardManager(type: .detail, coordinator: coordinator) + override var keyCommands: [UIKeyCommand]? { + return keyboardManager.keyCommands + } + + override var canBecomeFirstResponder: Bool { + return true + } + deinit { webView.removeFromSuperview() DetailViewControllerWebViewProvider.shared.enqueueWebView(webView) @@ -164,12 +173,20 @@ class DetailViewController: UIViewController { present(activityViewController, animated: true) } + // MARK: Keyboard Shortcuts + @objc func navigateToTimeline(_ sender: Any?) { + coordinator.navigateToTimeline() + } + // MARK: API func updateArticleSelection() { updateUI() reloadHTML() } + func focus() { + webView.becomeFirstResponder() + } } diff --git a/iOS/KeyboardManager.swift b/iOS/KeyboardManager.swift index d0ade5def..7bef03e86 100644 --- a/iOS/KeyboardManager.swift +++ b/iOS/KeyboardManager.swift @@ -39,21 +39,31 @@ private extension KeyboardManager { let specificCommands = specificEntries.compactMap { createKeyCommand(keyEntry: $0) } globalCommands.append(contentsOf: specificCommands) + + if type == .sidebar { + globalCommands.append(contentsOf: sidebarAuxilaryKeyCommands()) + } + keyCommands = globalCommands } func createKeyCommand(keyEntry: [String: Any]) -> UIKeyCommand? { guard let input = createKeyCommandInput(keyEntry: keyEntry) else { return nil } let modifiers = createKeyModifierFlags(keyEntry: keyEntry) - let action = NSSelectorFromString(keyEntry["action"] as! String) + let action = keyEntry["action"] as! String if let title = keyEntry["title"] as? String { - return UIKeyCommand(title: title, image: nil, action: action, input: input, modifierFlags: modifiers, propertyList: nil, alternates: [], discoverabilityTitle: nil, attributes: [], state: .on) + return createKeyCommand(title: title, action: action, input: input, modifiers: modifiers) } else { - return UIKeyCommand(input: input, modifierFlags: modifiers, action: action) + return UIKeyCommand(input: input, modifierFlags: modifiers, action: NSSelectorFromString(action)) } } + func createKeyCommand(title: String, action: String, input: String, modifiers: UIKeyModifierFlags) -> UIKeyCommand { + let selector = NSSelectorFromString(action) + return UIKeyCommand(title: title, image: nil, action: selector, input: input, modifierFlags: modifiers, propertyList: nil, alternates: [], discoverabilityTitle: nil, attributes: [], state: .on) + } + func createKeyCommandInput(keyEntry: [String: Any]) -> String? { guard let key = keyEntry["key"] as? String else { return nil } @@ -106,4 +116,16 @@ private extension KeyboardManager { return flags } + func sidebarAuxilaryKeyCommands() -> [UIKeyCommand] { + var keys = [UIKeyCommand]() + + let nextUpTitle = NSLocalizedString("Select Next Up", comment: "Select Next Up") + keys.append(createKeyCommand(title: nextUpTitle, action: "selectNextUp:", input: UIKeyCommand.inputUpArrow, modifiers: [])) + + let nextDownTitle = NSLocalizedString("Select Next Down", comment: "Select Next Down") + keys.append(createKeyCommand(title: nextDownTitle, action: "selectNextDown:", input: UIKeyCommand.inputDownArrow, modifiers: [])) + + return keys + } + } diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index 3b6af74f8..301401607 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -382,6 +382,18 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { // MARK: Keyboard shortcuts + @objc func selectNextUp(_ sender: Any?) { + coordinator.selectPrevFeed() + } + + @objc func selectNextDown(_ sender: Any?) { + coordinator.selectNextFeed() + } + + @objc func navigateToTimeline(_ sender: Any?) { + coordinator.navigateToTimeline() + } + @objc func openInBrowser(_ sender: Any?) { coordinator.showBrowserForCurrentFeed() } @@ -389,7 +401,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { // MARK: API func updateFeedSelection() { - if let indexPath = coordinator.currentMasterIndexPath { + if let indexPath = coordinator.currentFeedIndexPath { if tableView.indexPathForSelectedRow != indexPath { tableView.selectRow(at: indexPath, animated: true, scrollPosition: .middle) } @@ -439,6 +451,10 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } + func focus() { + becomeFirstResponder() + } + } // MARK: MasterTableViewCellDelegate diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 68b0b4478..0b6ab4efd 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -98,7 +98,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } } - // MARK Actions + // MARK: Actions @IBAction func markAllAsRead(_ sender: Any) { @@ -125,6 +125,24 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner coordinator.selectNextUnread() } + // MARK: Keyboard shortcuts + + @objc func selectNextUp(_ sender: Any?) { + coordinator.selectPrevArticle() + } + + @objc func selectNextDown(_ sender: Any?) { + coordinator.selectNextArticle() + } + + @objc func navigateToSidebar(_ sender: Any?) { + coordinator.navigateToFeeds() + } + + @objc func navigateToDetail(_ sender: Any?) { + coordinator.navigateToDetail() + } + // MARK: API func reinitializeArticles() { @@ -142,6 +160,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner if tableView.indexPathForSelectedRow != indexPath { tableView.selectRow(at: indexPath, animated: animate, scrollPosition: .middle) } + } else { + tableView.selectRow(at: nil, animated: animate, scrollPosition: .none) } updateUI() } @@ -151,6 +171,10 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner navigationItem.searchController?.searchBar.selectedScopeButtonIndex = 1 } + func focus() { + becomeFirstResponder() + } + // MARK: - Table view override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 582cfd039..c9a458735 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -99,7 +99,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { return sections } - private(set) var currentMasterIndexPath: IndexPath? + private(set) var currentFeedIndexPath: IndexPath? var timelineName: String? { return (timelineFetcher as? DisplayNameProvider)?.nameForDisplay @@ -130,6 +130,61 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { private(set) var showFeedNames = false private(set) var showAvatars = false + var isPrevFeedAvailable: Bool { + guard let indexPath = currentFeedIndexPath else { + return false + } + return indexPath.section > 0 || indexPath.row > 0 + } + + var isNextFeedAvailable: Bool { + guard let indexPath = currentFeedIndexPath else { + return false + } + + let nextIndexPath: IndexPath = { + if indexPath.row + 1 >= shadowTable[indexPath.section].count { + return IndexPath(row: 0, section: indexPath.section + 1) + } else { + return IndexPath(row: indexPath.row + 1, section: indexPath.section) + } + }() + + return nextIndexPath.section < shadowTable.count && nextIndexPath.row < shadowTable[nextIndexPath.section].count + } + + var prevFeedIndexPath: IndexPath? { + guard isPrevFeedAvailable, let indexPath = currentFeedIndexPath else { + return nil + } + + let prevIndexPath: IndexPath = { + if indexPath.row - 1 < 0 { + return IndexPath(row: shadowTable[indexPath.section - 1].count - 1, section: indexPath.section - 1) + } else { + return IndexPath(row: indexPath.row - 1, section: indexPath.section) + } + }() + + return prevIndexPath + } + + var nextFeedIndexPath: IndexPath? { + guard isNextFeedAvailable, let indexPath = currentFeedIndexPath else { + return nil + } + + let nextIndexPath: IndexPath = { + if indexPath.row + 1 >= shadowTable[indexPath.section].count { + return IndexPath(row: 0, section: indexPath.section + 1) + } else { + return IndexPath(row: indexPath.row + 1, section: indexPath.section) + } + }() + + return nextIndexPath + } + var isPrevArticleAvailable: Bool { guard let indexPath = currentArticleIndexPath else { return false @@ -145,14 +200,14 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } var prevArticleIndexPath: IndexPath? { - guard let indexPath = currentArticleIndexPath else { + guard isPrevArticleAvailable, let indexPath = currentArticleIndexPath else { return nil } return IndexPath(row: indexPath.row - 1, section: indexPath.section) } var nextArticleIndexPath: IndexPath? { - guard let indexPath = currentArticleIndexPath else { + guard isNextArticleAvailable, let indexPath = currentArticleIndexPath else { return nil } return IndexPath(row: indexPath.row + 1, section: indexPath.section) @@ -372,7 +427,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { func unreadCountFor(_ node: Node) -> Int { // The coordinator supplies the unread count for the currently selected feed node - if let indexPath = currentMasterIndexPath, let selectedNode = nodeFor(indexPath), selectedNode == node { + if let indexPath = currentFeedIndexPath, let selectedNode = nodeFor(indexPath), selectedNode == node { return unreadCount } if let unreadCountProvider = node.representedObject as? UnreadCountProvider { @@ -492,7 +547,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { navControllerForTimeline().pushViewController(masterTimelineViewController!, animated: true) } - currentMasterIndexPath = indexPath + currentFeedIndexPath = indexPath if let ip = indexPath, let node = nodeFor(ip), let fetcher = node.representedObject as? ArticleFetcher { timelineFetcher = fetcher @@ -505,6 +560,18 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { selectArticle(nil) } + func selectPrevFeed() { + if let indexPath = prevFeedIndexPath { + selectFeed(indexPath) + } + } + + func selectNextFeed() { + if let indexPath = nextFeedIndexPath { + selectFeed(indexPath) + } + } + func selectArticle(_ indexPath: IndexPath?, automated: Bool = true) { currentArticleIndexPath = indexPath activityManager.reading(currentArticle) @@ -518,6 +585,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { let systemMessageViewController = UIStoryboard.main.instantiateController(ofType: SystemMessageViewController.self) installDetailController(systemMessageViewController) } + masterTimelineViewController?.updateArticleSelection(animate: true) return } @@ -555,7 +623,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { lastSearchScope = nil searchArticleIds = nil - if let ip = currentMasterIndexPath, let node = nodeFor(ip), let fetcher = node.representedObject as? ArticleFetcher { + if let ip = currentFeedIndexPath, let node = nodeFor(ip), let fetcher = node.representedObject as? ArticleFetcher { timelineFetcher = fetcher } else { timelineFetcher = nil @@ -674,7 +742,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { markArticles(Set([article]), statusKey: .starred, flag: !article.status.starred) } } - func toggleStar(for indexPath: IndexPath) { let article = articles[indexPath.row] @@ -727,7 +794,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } func showBrowserForCurrentFeed() { - if let ip = currentMasterIndexPath, let url = homePageURLForFeed(ip) { + if let ip = currentFeedIndexPath, let url = homePageURLForFeed(ip) { UIApplication.shared.open(url, options: [:]) } } @@ -746,6 +813,22 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { UIApplication.shared.open(url, options: [:]) } + func navigateToFeeds() { + masterFeedViewController?.focus() + selectArticle(nil) + } + + func navigateToTimeline() { + masterTimelineViewController?.focus() + if currentArticleIndexPath == nil { + selectArticle(IndexPath(row: 0, section: 0)) + } + } + + func navigateToDetail() { + detailViewController?.focus() + } + } // MARK: UISplitViewControllerDelegate @@ -941,7 +1024,7 @@ private extension SceneCoordinator { func selectNextUnreadFeedFetcher() { - guard let indexPath = currentMasterIndexPath else { + guard let indexPath = currentFeedIndexPath else { assertionFailure() return } @@ -1250,7 +1333,7 @@ private extension SceneCoordinator { masterNavigationController.viewControllers = [masterFeedViewController] } - if currentMasterIndexPath == nil && currentArticleIndexPath == nil { + if currentFeedIndexPath == nil && currentArticleIndexPath == nil { let wrappedSystemMessageController = fullyWrappedSystemMesssageController(showButton: false) rootSplitViewController.showDetailViewController(wrappedSystemMessageController, sender: self)