Skip to content

Commit 5de84ec

Browse files
authored
fix(editor): dismiss autocomplete popup on inert clicks and constrain it to the editor (#1833)
1 parent 8196b23 commit 5de84ec

9 files changed

Lines changed: 287 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434
- 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)
3535
- 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)
3636
- The filter autocomplete no longer pops up on empty input, and pressing Escape to close it no longer also closes the filter bar. (#1815)
37-
- 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)
37+
- 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)
3838

3939
## [0.55.0] - 2026-07-04
4040

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ final class SuggestionViewModel: ObservableObject {
2929
/// directly leaves the local key monitor installed.
3030
var onApply: (() -> Void)?
3131

32+
/// Invoked when a click lands on a non-interactive part of the panel (background,
33+
/// padding, rounded corners, divider, preview, or the "No Completions" label).
34+
/// The owning controller dismisses the window so the click is not swallowed.
35+
var onBackgroundTap: (() -> Void)?
36+
3237
private var cursorPosition: CursorPosition?
3338
private var syntaxHighlightedCache: [Int: NSAttributedString] = [:]
3439

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/View/SuggestionContentView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ struct SuggestionContentView: View {
3232
.frame(width: contentWidth)
3333
.background(Color(nsColor: model.themeBackground))
3434
.clipShape(RoundedRectangle(cornerRadius: 8.5))
35+
.contentShape(Rectangle())
36+
.onTapGesture {
37+
model.onBackgroundTap?()
38+
}
3539
}
3640

3741
private var suggestionList: some View {

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController+Window.swift

Lines changed: 26 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -14,66 +14,14 @@ internal final class SuggestionPanel: NSPanel {
1414
}
1515

1616
extension SuggestionController {
17-
/// Will constrain the window's frame to be within the visible screen and, when provided, the editor's bounds.
18-
///
19-
/// `editorFrame` is the editor pane in screen coordinates. The panel flips above the cursor when it would
20-
/// extend past the editor's bottom edge, so it never overlaps sibling chrome below the editor.
17+
/// Anchors the window to `cursorRect` and constrains it within the visible screen and, when provided,
18+
/// the editor's bounds. The anchor is retained and re-applied on every later resize (see
19+
/// ``applyPlacement(windowSize:)``), so the panel never drifts past the editor edges as its content
20+
/// grows or shrinks during the same session.
2121
public func constrainWindowToScreenEdges(cursorRect: NSRect, font: NSFont, editorFrame: NSRect? = nil) {
22-
guard let window = self.window,
23-
let screenFrame = window.screen?.visibleFrame else {
24-
return
25-
}
26-
27-
let windowSize = window.frame.size
28-
let padding: CGFloat = 22
29-
var newWindowOrigin = NSPoint(
30-
x: cursorRect.origin.x - Self.WINDOW_PADDING
31-
- CodeSuggestionLabelView.HORIZONTAL_PADDING - font.pointSize,
32-
y: cursorRect.origin.y
33-
)
34-
35-
// Keep the horizontal position within the screen and some padding
36-
let minX = screenFrame.minX + padding
37-
let maxX = screenFrame.maxX - windowSize.width - padding
38-
39-
if newWindowOrigin.x < minX {
40-
newWindowOrigin.x = minX
41-
} else if newWindowOrigin.x > maxX {
42-
newWindowOrigin.x = maxX
43-
}
44-
45-
let lowerLimit = max(screenFrame.minY, editorFrame?.minY ?? screenFrame.minY)
46-
let upperLimit = min(screenFrame.maxY, editorFrame?.maxY ?? screenFrame.maxY)
47-
48-
// Check if the window will drop below the editor (or screen) bottom.
49-
// We determine whether the window drops down or upwards by choosing which
50-
// corner of the window we will position: `setFrameOrigin` or `setFrameTopLeftPoint`
51-
if newWindowOrigin.y - windowSize.height < lowerLimit {
52-
// If the cursor itself is below the lower limit, pin the window there with some padding
53-
if newWindowOrigin.y < lowerLimit {
54-
newWindowOrigin.y = lowerLimit + padding
55-
} else {
56-
// Place above the cursor
57-
newWindowOrigin.y += cursorRect.height
58-
}
59-
60-
// Keep the top edge within the upper limit so the panel never overlaps chrome above the editor
61-
if newWindowOrigin.y + windowSize.height > upperLimit {
62-
newWindowOrigin.y = max(lowerLimit, upperLimit - windowSize.height)
63-
}
64-
65-
isWindowAboveCursor = true
66-
window.setFrameOrigin(newWindowOrigin)
67-
} else {
68-
// If the window goes above the upper limit, pin it there with padding
69-
let maxY = upperLimit - padding
70-
if newWindowOrigin.y > maxY {
71-
newWindowOrigin.y = maxY
72-
}
73-
74-
isWindowAboveCursor = false
75-
window.setFrameTopLeftPoint(newWindowOrigin)
76-
}
22+
guard let window = self.window else { return }
23+
placementAnchor = SuggestionPlacementAnchor(cursorRect: cursorRect, font: font, editorFrame: editorFrame)
24+
applyPlacement(windowSize: window.frame.size)
7725
}
7826

7927
func updateWindowSize(newSize: NSSize) {
@@ -83,16 +31,31 @@ extension SuggestionController {
8331
}
8432

8533
guard let window else { return }
86-
let oldFrame = window.frame
8734

8835
window.minSize = newSize
8936
window.maxSize = NSSize(width: CGFloat.infinity, height: newSize.height)
90-
9137
window.setContentSize(newSize)
9238

93-
if isWindowAboveCursor && oldFrame.size.height != newSize.height {
94-
window.setFrameOrigin(oldFrame.origin)
39+
applyPlacement(windowSize: newSize)
40+
}
41+
42+
private func applyPlacement(windowSize: NSSize) {
43+
guard let anchor = placementAnchor,
44+
let window = self.window,
45+
let screenFrame = window.screen?.visibleFrame else {
46+
return
9547
}
48+
49+
let placement = SuggestionWindowPlacement.compute(
50+
windowSize: windowSize,
51+
cursorRect: anchor.cursorRect,
52+
font: anchor.font,
53+
screenFrame: screenFrame,
54+
editorFrame: anchor.editorFrame
55+
)
56+
57+
isWindowAboveCursor = placement.isAboveCursor
58+
window.setFrameOrigin(placement.origin)
9659
}
9760

9861
func updateWindowSizeFromContent() {

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Window/SuggestionController.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ public final class SuggestionController: NSWindowController {
3232
/// Tracks when the window is placed above the cursor
3333
var isWindowAboveCursor = false
3434

35+
/// Anchor for the current completion session. Re-applied by ``applyPlacement(windowSize:)``
36+
/// on every resize so the panel re-derives its position from the cursor instead of drifting
37+
/// from its previous frame as the list grows.
38+
var placementAnchor: SuggestionPlacementAnchor?
39+
3540
var popover: NSPopover?
3641

3742
/// Holds the observer for the window resign notifications
@@ -53,6 +58,7 @@ public final class SuggestionController: NSWindowController {
5358
window.contentView = hostingView
5459

5560
model.onApply = { [weak self] in self?.close() }
61+
model.onBackgroundTap = { [weak self] in self?.close() }
5662

5763
NotificationCenter.default.addObserver(
5864
self,
@@ -205,6 +211,7 @@ public final class SuggestionController: NSWindowController {
205211

206212
firstResponderKVO?.invalidate()
207213
firstResponderKVO = nil
214+
placementAnchor = nil
208215
}
209216

210217
// MARK: - Cursors Updated
@@ -243,6 +250,9 @@ public final class SuggestionController: NSWindowController {
243250
) { [weak self] event in
244251
guard let self else { return event }
245252
if let panel = self.window, event.window === panel {
253+
if event.type != .leftMouseDown {
254+
self.close()
255+
}
246256
return event
247257
}
248258
self.close()
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//
2+
// SuggestionWindowPlacement.swift
3+
// CodeEditSourceEditor
4+
//
5+
6+
import AppKit
7+
8+
/// The fixed anchor for a completion session: where the caret is, the editor font,
9+
/// and the editor's screen frame. Retained by ``SuggestionController`` so placement can be
10+
/// re-derived on every resize instead of patched from the panel's previous frame.
11+
internal struct SuggestionPlacementAnchor {
12+
let cursorRect: NSRect
13+
let font: NSFont
14+
let editorFrame: NSRect?
15+
}
16+
17+
/// Pure placement math for the suggestion panel. Operates on plain rects so it is unit testable
18+
/// without an `NSWindow`, and is re-invoked on every resize so the panel is re-derived from the
19+
/// same caret anchor rather than drifting from its last frame as its content grows.
20+
internal enum SuggestionWindowPlacement {
21+
internal static let edgePadding: CGFloat = 22
22+
23+
internal struct Placement: Equatable {
24+
let origin: NSPoint
25+
let isAboveCursor: Bool
26+
}
27+
28+
internal static func compute(
29+
windowSize: NSSize,
30+
cursorRect: NSRect,
31+
font: NSFont,
32+
screenFrame: NSRect,
33+
editorFrame: NSRect?
34+
) -> Placement {
35+
let x = clampedX(
36+
windowSize: windowSize,
37+
cursorRect: cursorRect,
38+
font: font,
39+
screenFrame: screenFrame,
40+
editorFrame: editorFrame
41+
)
42+
let (y, isAboveCursor) = clampedY(
43+
windowSize: windowSize,
44+
cursorRect: cursorRect,
45+
screenFrame: screenFrame,
46+
editorFrame: editorFrame
47+
)
48+
return Placement(origin: NSPoint(x: x, y: y), isAboveCursor: isAboveCursor)
49+
}
50+
51+
private static func clampedX(
52+
windowSize: NSSize,
53+
cursorRect: NSRect,
54+
font: NSFont,
55+
screenFrame: NSRect,
56+
editorFrame: NSRect?
57+
) -> CGFloat {
58+
let anchorX = cursorRect.origin.x - SuggestionController.WINDOW_PADDING
59+
- CodeSuggestionLabelView.HORIZONTAL_PADDING - font.pointSize
60+
61+
let leftLimit = max(screenFrame.minX, editorFrame?.minX ?? screenFrame.minX)
62+
let rightLimit = min(screenFrame.maxX, editorFrame?.maxX ?? screenFrame.maxX)
63+
64+
let minX = leftLimit + edgePadding
65+
let maxX = rightLimit - windowSize.width - edgePadding
66+
67+
guard maxX >= minX else { return minX }
68+
if anchorX < minX { return minX }
69+
if anchorX > maxX { return maxX }
70+
return anchorX
71+
}
72+
73+
private static func clampedY(
74+
windowSize: NSSize,
75+
cursorRect: NSRect,
76+
screenFrame: NSRect,
77+
editorFrame: NSRect?
78+
) -> (y: CGFloat, isAboveCursor: Bool) {
79+
let lowerLimit = max(screenFrame.minY, editorFrame?.minY ?? screenFrame.minY)
80+
let upperLimit = min(screenFrame.maxY, editorFrame?.maxY ?? screenFrame.maxY)
81+
82+
guard cursorRect.origin.y - windowSize.height < lowerLimit else {
83+
var topLeftY = cursorRect.origin.y
84+
let maxTopLeftY = upperLimit - edgePadding
85+
if topLeftY > maxTopLeftY {
86+
topLeftY = maxTopLeftY
87+
}
88+
return (topLeftY - windowSize.height, false)
89+
}
90+
91+
var bottomLeftY: CGFloat
92+
if cursorRect.origin.y < lowerLimit {
93+
bottomLeftY = lowerLimit + edgePadding
94+
} else {
95+
bottomLeftY = cursorRect.origin.y + cursorRect.height
96+
}
97+
98+
if bottomLeftY + windowSize.height > upperLimit {
99+
bottomLeftY = max(lowerLimit, upperLimit - windowSize.height)
100+
}
101+
102+
return (bottomLeftY, true)
103+
}
104+
}

LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/CodeSuggestion/SuggestionApplyTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,22 @@ final class SuggestionApplyTests: XCTestCase {
122122
XCTAssertNil(controller.model.activeTextView)
123123
XCTAssertTrue(controller.model.items.isEmpty)
124124
}
125+
126+
@MainActor
127+
func test_onBackgroundTap_closesControllerAndClearsModelState() throws {
128+
let controller = SuggestionController()
129+
let textViewController = Mock.textViewController(theme: Mock.theme())
130+
131+
controller.model.activeTextView = textViewController
132+
controller.model.delegate = StubSuggestionDelegate()
133+
controller.model.items = [StubSuggestionEntry(label: "users")]
134+
controller.model.selectedIndex = 0
135+
136+
controller.model.onBackgroundTap?()
137+
138+
XCTAssertNil(controller.model.activeTextView)
139+
XCTAssertTrue(controller.model.items.isEmpty)
140+
}
125141
}
126142

127143
private final class StubSuggestionDelegate: CodeSuggestionDelegate {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import AppKit
2+
@testable import CodeEditSourceEditor
3+
import XCTest
4+
5+
final class SuggestionControllerPlacementTests: XCTestCase {
6+
@MainActor
7+
func test_updateWindowSize_reclampsXAfterGrowingPastInitialPlacement() throws {
8+
let controller = SuggestionController()
9+
guard let window = controller.window, let screen = window.screen else {
10+
throw XCTSkip("No screen available in this environment")
11+
}
12+
13+
let editorFrame = NSRect(
14+
x: screen.visibleFrame.minX + 50,
15+
y: screen.visibleFrame.minY + 50,
16+
width: 400,
17+
height: 300
18+
)
19+
let cursorRect = NSRect(x: editorFrame.maxX - 30, y: editorFrame.minY + 150, width: 6, height: 16)
20+
21+
controller.constrainWindowToScreenEdges(
22+
cursorRect: cursorRect, font: .systemFont(ofSize: 12), editorFrame: editorFrame
23+
)
24+
controller.updateWindowSize(newSize: NSSize(width: 280, height: 200))
25+
26+
XCTAssertLessThanOrEqual(controller.window?.frame.maxX ?? .infinity, editorFrame.maxX)
27+
}
28+
}

0 commit comments

Comments
 (0)