Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -205,6 +211,7 @@ public final class SuggestionController: NSWindowController {

firstResponderKVO?.invalidate()
firstResponderKVO = nil
placementAnchor = nil
}

// MARK: - Cursors Updated
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +84 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the top clamp from pushing the popup below the editor

When the caret is near the top of a short editor and the popup height still technically fits below it, this top-edge padding clamp can lower topLeftY and then return topLeftY - windowSize.height without rechecking lowerLimit. For example, with an editor from y=100...250, a 140pt popup, and a caret at y=245, the unclamped popup would fit at y=105...245, but clamping the top to upperLimit - 22 returns an origin of 88, so the resized popup overlaps the status/buttons below the editor—the case this change is trying to prevent during resize re-placement.

Useful? React with 👍 / 👎.

}

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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading