diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d1fed34b..4faed320d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Pressing Escape to dismiss the SQL autocomplete popup no longer moves focus out of the editor, so you can keep typing. (#1845) + ## [0.56.1] - 2026-07-09 ### Removed diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/TextViewController+Suggestions.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/TextViewController+Suggestions.swift new file mode 100644 index 000000000..98fd4ed0b --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/TextViewController+Suggestions.swift @@ -0,0 +1,22 @@ +// +// TextViewController+Suggestions.swift +// CodeEditSourceEditor +// + +import AppKit + +public extension TextViewController { + /// Whether the completion popup is currently showing for this controller. + var isShowingCompletions: Bool { + SuggestionController.shared.isVisible && SuggestionController.shared.model.activeTextView === self + } + + /// Dismisses the completion popup when it is showing for this controller. + /// Returns whether a popup was dismissed. + @discardableResult + func dismissCompletions() -> Bool { + guard isShowingCompletions else { return false } + SuggestionController.shared.close() + return true + } +} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift index aac643880..39e308626 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift @@ -325,20 +325,19 @@ extension TextViewController { } private func handleShowCompletions(_ event: NSEvent) -> NSEvent? { - if let completionDelegate = self.completionDelegate, - let cursorPosition = cursorPositions.first { - if SuggestionController.shared.isVisible { - SuggestionController.shared.close() - return event - } - SuggestionController.shared.showCompletions( - textView: self, - delegate: completionDelegate, - cursorPosition: cursorPosition, - isManualTrigger: true - ) + guard let completionDelegate = self.completionDelegate, + let cursorPosition = cursorPositions.first else { + return event + } + if dismissCompletions() { return nil } - return event + SuggestionController.shared.showCompletions( + textView: self, + delegate: completionDelegate, + cursorPosition: cursorPosition, + isManualTrigger: true + ) + return nil } } diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/TextViewControllerSuggestionsTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/TextViewControllerSuggestionsTests.swift new file mode 100644 index 000000000..1a81a3899 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/TextViewControllerSuggestionsTests.swift @@ -0,0 +1,56 @@ +import AppKit +@testable import CodeEditSourceEditor +import XCTest + +final class TextViewControllerSuggestionsTests: XCTestCase { + @MainActor + func test_isShowingCompletions_falseWhenNothingShowing() { + let controller = Mock.textViewController(theme: Mock.theme()) + defer { resetSharedSuggestions() } + + XCTAssertFalse(controller.isShowingCompletions) + } + + @MainActor + func test_dismissCompletions_returnsFalseWhenNothingShowing() { + let controller = Mock.textViewController(theme: Mock.theme()) + defer { resetSharedSuggestions() } + + XCTAssertFalse(controller.dismissCompletions()) + } + + @MainActor + func test_isShowingCompletions_trueOnlyForOwningController() { + let owner = Mock.textViewController(theme: Mock.theme()) + let other = Mock.textViewController(theme: Mock.theme()) + defer { resetSharedSuggestions() } + + SuggestionController.shared.model.activeTextView = owner + SuggestionController.shared.window?.orderFrontRegardless() + + XCTAssertTrue(owner.isShowingCompletions) + XCTAssertFalse(other.isShowingCompletions) + } + + @MainActor + func test_dismissCompletions_closesPopupAndReturnsTrueWhenVisible() { + let owner = Mock.textViewController(theme: Mock.theme()) + defer { resetSharedSuggestions() } + + SuggestionController.shared.model.activeTextView = owner + SuggestionController.shared.window?.orderFrontRegardless() + XCTAssertTrue(owner.isShowingCompletions) + + let dismissed = owner.dismissCompletions() + + XCTAssertTrue(dismissed) + XCTAssertFalse(SuggestionController.shared.isVisible) + XCTAssertFalse(owner.isShowingCompletions) + } + + @MainActor + private func resetSharedSuggestions() { + SuggestionController.shared.close() + SuggestionController.shared.model.activeTextView = nil + } +} diff --git a/TablePro/Core/Vim/VimKeyInterceptor.swift b/TablePro/Core/Vim/VimKeyInterceptor.swift index 66a4e6517..530ec8afb 100644 --- a/TablePro/Core/Vim/VimKeyInterceptor.swift +++ b/TablePro/Core/Vim/VimKeyInterceptor.swift @@ -194,11 +194,6 @@ final class VimKeyInterceptor { } private func closeSuggestionPopup() { - guard let window = controller?.textView.window else { return } - for childWindow in window.childWindows ?? [] { - if childWindow.windowController is SuggestionController { - childWindow.windowController?.close() - } - } + controller?.dismissCompletions() } } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 4ece4318d..1e6360c4b 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -92,10 +92,12 @@ struct PasteboardCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .selectAll)) Button("Clear Selection") { - // Route the Esc key equivalent to Vim first when the active editor is - // in a non-normal mode — the menu shortcut otherwise preempts the - // local event monitor and Vim never sees the keystroke. - if !EditorEventRouter.shared.handleVimEscapeFromMenu() { + // The bare-Escape key equivalent preempts the editor's local event + // monitors. Route it to the focused editor first so it dismisses the + // autocomplete popup, exits Vim insert mode, and keeps the caret in the + // editor. Fall back to clearing the data-grid selection only when no + // editor handled it. + if !EditorEventRouter.shared.handleEscapeFromMenu() { NSApp.sendAction(#selector(NSResponder.cancelOperation(_:)), to: nil, from: nil) } } diff --git a/TablePro/Views/Editor/EditorEventRouter.swift b/TablePro/Views/Editor/EditorEventRouter.swift index dfc83fb9d..766a4830b 100644 --- a/TablePro/Views/Editor/EditorEventRouter.swift +++ b/TablePro/Views/Editor/EditorEventRouter.swift @@ -109,14 +109,17 @@ internal final class EditorEventRouter { coordinator.performFormatSQL() } - /// Called by the SwiftUI "Clear Selection" menu when its Esc key equivalent fires. - /// Routes the keystroke to the active editor's Vim engine if it is in a non-normal - /// mode. Returns true when Vim consumed the escape — caller should suppress its - /// normal cancelOperation fallback in that case. + /// Called by the SwiftUI "Clear Selection" menu when its bare-Escape key equivalent + /// fires. A bare-key menu equivalent preempts every local event monitor in the key + /// window, so the focused editor's completion popup, Vim interceptor, and + /// first-responder handling never see the keystroke. Routes it to the key window's + /// editor so it can dismiss its completion popup, hand the escape to Vim, and restore + /// first responder. Returns whether the editor consumed the escape so the caller + /// skips its cancelOperation fallback (the data grid's Clear Selection). @discardableResult - internal func handleVimEscapeFromMenu() -> Bool { + internal func handleEscapeFromMenu() -> Bool { guard let (coordinator, _) = editor(for: NSApp.keyWindow) else { return false } - return coordinator.handleVimEscapeFromMenu() + return coordinator.handleEscapeFromMenu() } // MARK: - Lookup diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 3032d156a..ad554a24b 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -458,15 +458,45 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } } - // MARK: - Vim External Escape Routing + // MARK: - Menu Escape Routing + + /// Called by `EditorEventRouter.handleEscapeFromMenu()` when the "Clear Selection" + /// menu item's bare-Escape key equivalent fires. That key equivalent preempts the + /// editor's local event monitors, so the completion popup, Vim, and first-responder + /// handling that would normally run on Escape never do. Dismisses an open completion + /// popup, hands the keystroke to Vim when it is mid-command, and restores first + /// responder and the caret when this editor was the focused surface. Returns whether + /// the editor consumed the escape so the menu skips its cancelOperation fallback. + @discardableResult + func handleEscapeFromMenu() -> Bool { + let wasFocused = wasEditorFocused + controller?.dismissCompletions() + let vimHandled = handleVimEscapeFromMenu() + + if wasFocused { + reclaimFirstResponder() + } + + return wasFocused || vimHandled + } - /// Called by the menu's "Clear Selection" (Esc) shortcut so a SwiftUI key - /// equivalent that preempts the local event monitor still flips Vim back to - /// normal mode instead of getting silently swallowed. - func handleVimEscapeFromMenu() -> Bool { + private func handleVimEscapeFromMenu() -> Bool { vimKeyInterceptor?.handleEscapeFromExternalSource() ?? false } + /// `TextView.resignFirstResponder()` calls `removeCursors()`, destroying the caret + /// subview, and `makeFirstResponder` alone does not recreate it (only a window-level + /// `didBecomeKeyNotification` does, which does not fire for an in-window first + /// responder change). Re-applying `cursorPositions` rebuilds the cursor views now + /// that the text view is first responder again. + private func reclaimFirstResponder() { + guard let controller, let textView = controller.textView, let window = textView.window, + window.firstResponder !== textView else { return } + guard window.makeFirstResponder(textView) else { return } + guard !controller.cursorPositions.isEmpty else { return } + controller.setCursorPositions(controller.cursorPositions) + } + // MARK: - First Responder Tracking func checkFirstResponderChange() { diff --git a/TableProTests/Views/Editor/SQLEditorCoordinatorEscapeMenuTests.swift b/TableProTests/Views/Editor/SQLEditorCoordinatorEscapeMenuTests.swift new file mode 100644 index 000000000..41f337e54 --- /dev/null +++ b/TableProTests/Views/Editor/SQLEditorCoordinatorEscapeMenuTests.swift @@ -0,0 +1,27 @@ +// +// SQLEditorCoordinatorEscapeMenuTests.swift +// TableProTests +// +// Tests for SQLEditorCoordinator.handleEscapeFromMenu() routing. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +@Suite("SQLEditorCoordinator menu escape") +struct SQLEditorCoordinatorEscapeMenuTests { + @Test("handleEscapeFromMenu returns false when no editor is focused") + func returnsFalseWhenNothingToHandle() { + let coordinator = SQLEditorCoordinator() + #expect(coordinator.handleEscapeFromMenu() == false) + } + + @Test("handleEscapeFromMenu stays false after destroy so the grid keeps Escape") + func staysFalseAfterDestroy() { + let coordinator = SQLEditorCoordinator() + coordinator.destroy() + #expect(coordinator.handleEscapeFromMenu() == false) + } +} diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index 30bf57653..afcadb695 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -21,7 +21,7 @@ Autocomplete suggests keywords, tables, columns, and functions based on where yo /> -Suggestions appear after 2 characters, after `.`, or after keywords like SELECT/FROM/JOIN. 50ms debounce. Press `Escape` to dismiss. +Suggestions appear after 2 characters, after `.`, or after keywords like SELECT/FROM/JOIN. 50ms debounce. Press `Escape` to dismiss and keep typing. ## Completion Types