From 4ecb50f5e71da50b45809c3b7a1a43ccc449df97 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 8 Jul 2026 19:16:58 +0700 Subject: [PATCH] fix(editor): dismiss autocomplete popup on inert clicks and constrain it to the editor --- CHANGELOG.md | 2 +- .../Model/SuggestionViewModel.swift | 5 + .../View/SuggestionContentView.swift | 4 + .../Window/SuggestionController+Window.swift | 89 +++++---------- .../Window/SuggestionController.swift | 10 ++ .../Window/SuggestionWindowPlacement.swift | 104 ++++++++++++++++++ .../CodeSuggestion/SuggestionApplyTests.swift | 16 +++ .../SuggestionControllerPlacementTests.swift | 28 +++++ .../SuggestionWindowPlacementTests.swift | 93 ++++++++++++++++ 9 files changed, 287 insertions(+), 64 deletions(-) create mode 100644 LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionWindowPlacement.swift create mode 100644 LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionControllerPlacementTests.swift create mode 100644 LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionWindowPlacementTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f102f667..8ac44b402 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hidden columns stay hidden. The columns you choose to show are remembered per table across sessions, and resizing a column no longer brings the hidden ones back. Column widths and order are remembered per table too, now kept separately for each connection, database, and schema so two tables with the same name no longer overwrite each other's layout. A Reset Columns button in the Columns popover puts widths, order, and visibility back to defaults. Existing saved column layouts (widths, order, and which columns are hidden) reset once as part of this change. (#1815) - Query and filter errors now appear in a scrollable banner you can read, select, and copy, with a Fix with AI button, instead of a small dialog that cut the message off. (#1815) - The filter autocomplete no longer pops up on empty input, and pressing Escape to close it no longer also closes the filter bar. (#1815) -- The editor autocomplete popup no longer draws over the status bar or the Columns and Add buttons, and clicking outside it now dismisses it. This fixes the bottom-right buttons and the editor mouse going dead after the popup had appeared. (#1815) +- The editor autocomplete popup no longer draws over the status bar, the Columns and Add buttons, or the editor's sides as it grows. Clicking anywhere on the popup that isn't a suggestion now dismisses it, instead of doing nothing and leaving the editor mouse dead. (#1815, #1831) ## [0.55.0] - 2026-07-04 diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift index e92f97c65..268cf6d5f 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift @@ -29,6 +29,11 @@ final class SuggestionViewModel: ObservableObject { /// directly leaves the local key monitor installed. var onApply: (() -> Void)? + /// Invoked when a click lands on a non-interactive part of the panel (background, + /// padding, rounded corners, divider, preview, or the "No Completions" label). + /// The owning controller dismisses the window so the click is not swallowed. + var onBackgroundTap: (() -> Void)? + private var cursorPosition: CursorPosition? private var syntaxHighlightedCache: [Int: NSAttributedString] = [:] diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift index a6d276b0c..c63e7f4a1 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift @@ -32,6 +32,10 @@ struct SuggestionContentView: View { .frame(width: contentWidth) .background(Color(nsColor: model.themeBackground)) .clipShape(RoundedRectangle(cornerRadius: 8.5)) + .contentShape(Rectangle()) + .onTapGesture { + model.onBackgroundTap?() + } } private var suggestionList: some View { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift index a81895d51..04ae1c108 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift @@ -14,66 +14,14 @@ internal final class SuggestionPanel: NSPanel { } extension SuggestionController { - /// Will constrain the window's frame to be within the visible screen and, when provided, the editor's bounds. - /// - /// `editorFrame` is the editor pane in screen coordinates. The panel flips above the cursor when it would - /// extend past the editor's bottom edge, so it never overlaps sibling chrome below the editor. + /// Anchors the window to `cursorRect` and constrains it within the visible screen and, when provided, + /// the editor's bounds. The anchor is retained and re-applied on every later resize (see + /// ``applyPlacement(windowSize:)``), so the panel never drifts past the editor edges as its content + /// grows or shrinks during the same session. public func constrainWindowToScreenEdges(cursorRect: NSRect, font: NSFont, editorFrame: NSRect? = nil) { - guard let window = self.window, - let screenFrame = window.screen?.visibleFrame else { - return - } - - let windowSize = window.frame.size - let padding: CGFloat = 22 - var newWindowOrigin = NSPoint( - x: cursorRect.origin.x - Self.WINDOW_PADDING - - CodeSuggestionLabelView.HORIZONTAL_PADDING - font.pointSize, - y: cursorRect.origin.y - ) - - // Keep the horizontal position within the screen and some padding - let minX = screenFrame.minX + padding - let maxX = screenFrame.maxX - windowSize.width - padding - - if newWindowOrigin.x < minX { - newWindowOrigin.x = minX - } else if newWindowOrigin.x > maxX { - newWindowOrigin.x = maxX - } - - let lowerLimit = max(screenFrame.minY, editorFrame?.minY ?? screenFrame.minY) - let upperLimit = min(screenFrame.maxY, editorFrame?.maxY ?? screenFrame.maxY) - - // Check if the window will drop below the editor (or screen) bottom. - // We determine whether the window drops down or upwards by choosing which - // corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint` - if newWindowOrigin.y - windowSize.height < lowerLimit { - // If the cursor itself is below the lower limit, pin the window there with some padding - if newWindowOrigin.y < lowerLimit { - newWindowOrigin.y = lowerLimit + padding - } else { - // Place above the cursor - newWindowOrigin.y += cursorRect.height - } - - // Keep the top edge within the upper limit so the panel never overlaps chrome above the editor - if newWindowOrigin.y + windowSize.height > upperLimit { - newWindowOrigin.y = max(lowerLimit, upperLimit - windowSize.height) - } - - isWindowAboveCursor = true - window.setFrameOrigin(newWindowOrigin) - } else { - // If the window goes above the upper limit, pin it there with padding - let maxY = upperLimit - padding - if newWindowOrigin.y > maxY { - newWindowOrigin.y = maxY - } - - isWindowAboveCursor = false - window.setFrameTopLeftPoint(newWindowOrigin) - } + guard let window = self.window else { return } + placementAnchor = SuggestionPlacementAnchor(cursorRect: cursorRect, font: font, editorFrame: editorFrame) + applyPlacement(windowSize: window.frame.size) } func updateWindowSize(newSize: NSSize) { @@ -83,16 +31,31 @@ extension SuggestionController { } guard let window else { return } - let oldFrame = window.frame window.minSize = newSize window.maxSize = NSSize(width: CGFloat.infinity, height: newSize.height) - window.setContentSize(newSize) - if isWindowAboveCursor && oldFrame.size.height != newSize.height { - window.setFrameOrigin(oldFrame.origin) + applyPlacement(windowSize: newSize) + } + + private func applyPlacement(windowSize: NSSize) { + guard let anchor = placementAnchor, + let window = self.window, + let screenFrame = window.screen?.visibleFrame else { + return } + + let placement = SuggestionWindowPlacement.compute( + windowSize: windowSize, + cursorRect: anchor.cursorRect, + font: anchor.font, + screenFrame: screenFrame, + editorFrame: anchor.editorFrame + ) + + isWindowAboveCursor = placement.isAboveCursor + window.setFrameOrigin(placement.origin) } func updateWindowSizeFromContent() { diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift index f4e21b15c..e935a6f8e 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift @@ -32,6 +32,11 @@ public final class SuggestionController: NSWindowController { /// Tracks when the window is placed above the cursor var isWindowAboveCursor = false + /// Anchor for the current completion session. Re-applied by ``applyPlacement(windowSize:)`` + /// on every resize so the panel re-derives its position from the cursor instead of drifting + /// from its previous frame as the list grows. + var placementAnchor: SuggestionPlacementAnchor? + var popover: NSPopover? /// Holds the observer for the window resign notifications @@ -53,6 +58,7 @@ public final class SuggestionController: NSWindowController { window.contentView = hostingView model.onApply = { [weak self] in self?.close() } + model.onBackgroundTap = { [weak self] in self?.close() } NotificationCenter.default.addObserver( self, @@ -205,6 +211,7 @@ public final class SuggestionController: NSWindowController { firstResponderKVO?.invalidate() firstResponderKVO = nil + placementAnchor = nil } // MARK: - Cursors Updated @@ -243,6 +250,9 @@ public final class SuggestionController: NSWindowController { ) { [weak self] event in guard let self else { return event } if let panel = self.window, event.window === panel { + if event.type != .leftMouseDown { + self.close() + } return event } self.close() diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionWindowPlacement.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionWindowPlacement.swift new file mode 100644 index 000000000..2d333f90d --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionWindowPlacement.swift @@ -0,0 +1,104 @@ +// +// SuggestionWindowPlacement.swift +// CodeEditSourceEditor +// + +import AppKit + +/// The fixed anchor for a completion session: where the caret is, the editor font, +/// and the editor's screen frame. Retained by ``SuggestionController`` so placement can be +/// re-derived on every resize instead of patched from the panel's previous frame. +internal struct SuggestionPlacementAnchor { + let cursorRect: NSRect + let font: NSFont + let editorFrame: NSRect? +} + +/// Pure placement math for the suggestion panel. Operates on plain rects so it is unit testable +/// without an `NSWindow`, and is re-invoked on every resize so the panel is re-derived from the +/// same caret anchor rather than drifting from its last frame as its content grows. +internal enum SuggestionWindowPlacement { + internal static let edgePadding: CGFloat = 22 + + internal struct Placement: Equatable { + let origin: NSPoint + let isAboveCursor: Bool + } + + internal static func compute( + windowSize: NSSize, + cursorRect: NSRect, + font: NSFont, + screenFrame: NSRect, + editorFrame: NSRect? + ) -> Placement { + let x = clampedX( + windowSize: windowSize, + cursorRect: cursorRect, + font: font, + screenFrame: screenFrame, + editorFrame: editorFrame + ) + let (y, isAboveCursor) = clampedY( + windowSize: windowSize, + cursorRect: cursorRect, + screenFrame: screenFrame, + editorFrame: editorFrame + ) + return Placement(origin: NSPoint(x: x, y: y), isAboveCursor: isAboveCursor) + } + + private static func clampedX( + windowSize: NSSize, + cursorRect: NSRect, + font: NSFont, + screenFrame: NSRect, + editorFrame: NSRect? + ) -> CGFloat { + let anchorX = cursorRect.origin.x - SuggestionController.WINDOW_PADDING + - CodeSuggestionLabelView.HORIZONTAL_PADDING - font.pointSize + + let leftLimit = max(screenFrame.minX, editorFrame?.minX ?? screenFrame.minX) + let rightLimit = min(screenFrame.maxX, editorFrame?.maxX ?? screenFrame.maxX) + + let minX = leftLimit + edgePadding + let maxX = rightLimit - windowSize.width - edgePadding + + guard maxX >= minX else { return minX } + if anchorX < minX { return minX } + if anchorX > maxX { return maxX } + return anchorX + } + + private static func clampedY( + windowSize: NSSize, + cursorRect: NSRect, + screenFrame: NSRect, + editorFrame: NSRect? + ) -> (y: CGFloat, isAboveCursor: Bool) { + let lowerLimit = max(screenFrame.minY, editorFrame?.minY ?? screenFrame.minY) + let upperLimit = min(screenFrame.maxY, editorFrame?.maxY ?? screenFrame.maxY) + + guard cursorRect.origin.y - windowSize.height < lowerLimit else { + var topLeftY = cursorRect.origin.y + let maxTopLeftY = upperLimit - edgePadding + if topLeftY > maxTopLeftY { + topLeftY = maxTopLeftY + } + return (topLeftY - windowSize.height, false) + } + + var bottomLeftY: CGFloat + if cursorRect.origin.y < lowerLimit { + bottomLeftY = lowerLimit + edgePadding + } else { + bottomLeftY = cursorRect.origin.y + cursorRect.height + } + + if bottomLeftY + windowSize.height > upperLimit { + bottomLeftY = max(lowerLimit, upperLimit - windowSize.height) + } + + return (bottomLeftY, true) + } +} diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionApplyTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionApplyTests.swift index 7de63dfe6..1079fc0b9 100644 --- a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionApplyTests.swift +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionApplyTests.swift @@ -122,6 +122,22 @@ final class SuggestionApplyTests: XCTestCase { XCTAssertNil(controller.model.activeTextView) XCTAssertTrue(controller.model.items.isEmpty) } + + @MainActor + func test_onBackgroundTap_closesControllerAndClearsModelState() throws { + let controller = SuggestionController() + let textViewController = Mock.textViewController(theme: Mock.theme()) + + controller.model.activeTextView = textViewController + controller.model.delegate = StubSuggestionDelegate() + controller.model.items = [StubSuggestionEntry(label: "users")] + controller.model.selectedIndex = 0 + + controller.model.onBackgroundTap?() + + XCTAssertNil(controller.model.activeTextView) + XCTAssertTrue(controller.model.items.isEmpty) + } } private final class StubSuggestionDelegate: CodeSuggestionDelegate { diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionControllerPlacementTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionControllerPlacementTests.swift new file mode 100644 index 000000000..092792da7 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionControllerPlacementTests.swift @@ -0,0 +1,28 @@ +import AppKit +@testable import CodeEditSourceEditor +import XCTest + +final class SuggestionControllerPlacementTests: XCTestCase { + @MainActor + func test_updateWindowSize_reclampsXAfterGrowingPastInitialPlacement() throws { + let controller = SuggestionController() + guard let window = controller.window, let screen = window.screen else { + throw XCTSkip("No screen available in this environment") + } + + let editorFrame = NSRect( + x: screen.visibleFrame.minX + 50, + y: screen.visibleFrame.minY + 50, + width: 400, + height: 300 + ) + let cursorRect = NSRect(x: editorFrame.maxX - 30, y: editorFrame.minY + 150, width: 6, height: 16) + + controller.constrainWindowToScreenEdges( + cursorRect: cursorRect, font: .systemFont(ofSize: 12), editorFrame: editorFrame + ) + controller.updateWindowSize(newSize: NSSize(width: 280, height: 200)) + + XCTAssertLessThanOrEqual(controller.window?.frame.maxX ?? .infinity, editorFrame.maxX) + } +} diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionWindowPlacementTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionWindowPlacementTests.swift new file mode 100644 index 000000000..6017389d4 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionWindowPlacementTests.swift @@ -0,0 +1,93 @@ +import AppKit +@testable import CodeEditSourceEditor +import XCTest + +final class SuggestionWindowPlacementTests: XCTestCase { + private let screenFrame = NSRect(x: 0, y: 0, width: 1440, height: 900) + private let editorFrame = NSRect(x: 100, y: 150, width: 900, height: 600) + private let font = NSFont.systemFont(ofSize: 12) + + func test_compute_clampsCursorNearRightEdgeWithinEditorMaxX() { + let cursorRect = NSRect(x: 980, y: 500, width: 6, height: 16) + let windowSize = NSSize(width: 280, height: 200) + + let placement = SuggestionWindowPlacement.compute( + windowSize: windowSize, cursorRect: cursorRect, font: font, + screenFrame: screenFrame, editorFrame: editorFrame + ) + + XCTAssertGreaterThanOrEqual(placement.origin.x, editorFrame.minX) + XCTAssertLessThanOrEqual(placement.origin.x + windowSize.width, editorFrame.maxX) + } + + func test_compute_widthGrowthAcrossResizesStaysWithinEditorMaxX() { + let cursorRect = NSRect(x: 900, y: 500, width: 6, height: 16) + + let narrow = SuggestionWindowPlacement.compute( + windowSize: NSSize(width: 100, height: 200), cursorRect: cursorRect, font: font, + screenFrame: screenFrame, editorFrame: editorFrame + ) + let grown = SuggestionWindowPlacement.compute( + windowSize: NSSize(width: 280, height: 200), cursorRect: cursorRect, font: font, + screenFrame: screenFrame, editorFrame: editorFrame + ) + + XCTAssertLessThanOrEqual(narrow.origin.x + 100, editorFrame.maxX) + XCTAssertLessThanOrEqual(grown.origin.x + 280, editorFrame.maxX) + } + + func test_compute_leftAlignsWhenWiderThanEditor() { + let narrowEditorFrame = NSRect(x: 100, y: 150, width: 200, height: 600) + let cursorRect = NSRect(x: 200, y: 500, width: 6, height: 16) + let windowSize = NSSize(width: 280, height: 200) + + let placement = SuggestionWindowPlacement.compute( + windowSize: windowSize, cursorRect: cursorRect, font: font, + screenFrame: screenFrame, editorFrame: narrowEditorFrame + ) + + XCTAssertEqual(placement.origin.x, narrowEditorFrame.minX + SuggestionWindowPlacement.edgePadding) + } + + func test_compute_flipsAboveCursorWhenBelowSpaceIsInsufficient() { + let cursorRect = NSRect(x: 300, y: 200, width: 6, height: 16) + + let placement = SuggestionWindowPlacement.compute( + windowSize: NSSize(width: 280, height: 100), cursorRect: cursorRect, font: font, + screenFrame: screenFrame, editorFrame: editorFrame + ) + + XCTAssertTrue(placement.isAboveCursor) + XCTAssertGreaterThanOrEqual(placement.origin.y, editorFrame.minY) + } + + func test_compute_growingHeightAcrossResizesStaysWithinEditorMaxY() { + let cursorRect = NSRect(x: 300, y: 200, width: 6, height: 16) + + let mid = SuggestionWindowPlacement.compute( + windowSize: NSSize(width: 280, height: 400), cursorRect: cursorRect, font: font, + screenFrame: screenFrame, editorFrame: editorFrame + ) + let grown = SuggestionWindowPlacement.compute( + windowSize: NSSize(width: 280, height: 560), cursorRect: cursorRect, font: font, + screenFrame: screenFrame, editorFrame: editorFrame + ) + + XCTAssertTrue(mid.isAboveCursor) + XCTAssertTrue(grown.isAboveCursor) + XCTAssertLessThanOrEqual(mid.origin.y + 400, editorFrame.maxY) + XCTAssertEqual(grown.origin.y + 560, editorFrame.maxY, accuracy: 0.001) + } + + func test_compute_fallsBackToScreenFrameWhenEditorFrameIsNil() { + let cursorRect = NSRect(x: 1300, y: 500, width: 6, height: 16) + let windowSize = NSSize(width: 280, height: 200) + + let placement = SuggestionWindowPlacement.compute( + windowSize: windowSize, cursorRect: cursorRect, font: font, + screenFrame: screenFrame, editorFrame: nil + ) + + XCTAssertLessThanOrEqual(placement.origin.x + windowSize.width, screenFrame.maxX) + } +}