From c5653c2208218ca6ae3b530b0ddb0d9cde0d54f4 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 22 Jun 2026 16:22:57 +0300 Subject: [PATCH] Allow returning from global search with the escape key cmd+k jumps to the search tab but left users with no way back. Pressing escape in the search field now returns to the tab they came from (never into search itself). Escape is handled at the search field's pressesBegan, alongside the existing arrow-key navigation, since the focused UIKit field swallows SwiftUI keyboard shortcuts. --- .../Navigation/NavigationTabCoordinator.swift | 11 ++++++++++- .../UserSessionFlowCoordinator.swift | 3 +++ .../SearchScreen/SearchScreenCoordinator.swift | 3 +++ .../SearchScreen/SearchScreenModels.swift | 2 ++ .../SearchScreen/SearchScreenViewModel.swift | 2 ++ .../SearchScreen/View/SearchScreen.swift | 9 ++++++--- .../Sources/SearchScreenViewModelTests.swift | 18 ++++++++++++++++++ 7 files changed, 44 insertions(+), 4 deletions(-) diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index b7bef22b8..a22f54229 100644 --- a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -100,7 +100,16 @@ import SwiftUI } /// The currently selected tab's tag. - var selectedTab: Tag? + var selectedTab: Tag? { + didSet { + if oldValue != selectedTab { + previousTab = oldValue + } + } + } + + /// The tab that was selected before the current one, used to return to it (e.g. cancelling search). + private(set) var previousTab: Tag? // MARK: Sheets diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 0da890f78..5e9ad6e6b 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -229,6 +229,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { switch action { case .presentRoom(let roomID): handleAppRoute(.room(roomID: roomID, via: []), animated: true) + case .cancel: + // Return to the tab the user came from, but never back into search. + navigationTabCoordinator.selectedTab = navigationTabCoordinator.previousTab == .search ? .chats : navigationTabCoordinator.previousTab ?? .chats } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift index e663ed8d1..be5311da4 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -15,6 +15,7 @@ struct SearchScreenCoordinatorParameters { enum SearchScreenCoordinatorAction { case presentRoom(roomID: String) + case cancel } final class SearchScreenCoordinator: CoordinatorProtocol { @@ -38,6 +39,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol { switch action { case .presentRoom(let roomID): actionsSubject.send(.presentRoom(roomID: roomID)) + case .cancel: + actionsSubject.send(.cancel) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift index d8f423eb6..63a27d0f2 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift @@ -9,6 +9,7 @@ import Foundation enum SearchScreenViewModelAction { case presentRoom(roomID: String) + case cancel } struct SearchScreenViewState: BindableState { @@ -29,6 +30,7 @@ enum SearchScreenViewAction { case selectRoom(roomID: String) case reachedTop case reachedBottom + case cancel } struct SearchScreenRoom: Identifiable, Equatable { diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index 05a7baf2d..52d219e86 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -65,6 +65,8 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro updateVisibleRange(edge: .top) case .reachedBottom: updateVisibleRange(edge: .bottom) + case .cancel: + actionsSubject.send(.cancel) } } diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index 8273ba77b..9044c66d5 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -47,7 +47,8 @@ struct SearchScreen: View { } } .searchResultsKeyboardNavigation(moveUp: { moveSelection(backwards: true) }, - moveDown: { moveSelection(backwards: false) }) + moveDown: { moveSelection(backwards: false) }, + cancel: { context.send(viewAction: .cancel) }) // The TabView calls onAppear each time the search tab is selected, so the field re-focuses // and the selection resets on every switch. .onAppear { @@ -189,12 +190,12 @@ private struct SearchScreenRoomCellButtonStyle: ButtonStyle { } private extension View { - /// Forwards hardware up/down arrow presses from the system search field to the given closures. + /// Forwards hardware up/down arrow and escape presses from the system search field to the given closures. /// /// The `.search` role tab owns its text field, so there's no SwiftUI hook for its key presses. We reach the /// field through the navigation stack (as ``SearchFieldStyle`` does) and swap its class for one that overrides /// `pressesBegan` — the same key interception the old global search performed on its own field. - func searchResultsKeyboardNavigation(moveUp: @escaping () -> Void, moveDown: @escaping () -> Void) -> some View { + func searchResultsKeyboardNavigation(moveUp: @escaping () -> Void, moveDown: @escaping () -> Void, cancel: @escaping () -> Void) -> some View { introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in guard let textField = navigationController.navigationBar.topItem?.searchController?.searchBar.searchTextField else { return @@ -210,6 +211,8 @@ private extension View { moveUp() case .keyboardDownArrow: moveDown() + case .keyboardEscape: + cancel() default: return false } diff --git a/UnitTests/Sources/SearchScreenViewModelTests.swift b/UnitTests/Sources/SearchScreenViewModelTests.swift index 52c7e76f1..a862b9254 100644 --- a/UnitTests/Sources/SearchScreenViewModelTests.swift +++ b/UnitTests/Sources/SearchScreenViewModelTests.swift @@ -33,6 +33,8 @@ struct SearchScreenViewModelTests { switch action { case .presentRoom(let roomID): roomID == "2" + case .cancel: + false } } @@ -40,4 +42,20 @@ struct SearchScreenViewModelTests { try await deferred.fulfill() } + + @Test + func cancel() async throws { + let deferred = deferFulfillment(viewModel.actionsPublisher) { action in + switch action { + case .cancel: + true + default: + false + } + } + + context.send(viewAction: .cancel) + + try await deferred.fulfill() + } }