Fix autocomplete dropdown transparency using NSWindow

- Replace SwiftUI overlay with custom NSWindow implementation
- Use NSColor.controlBackgroundColor for opaque background
- Add proper click-outside-to-dismiss functionality
- Handle Swift concurrency and actor isolation correctly
- Inspired by SuggestionsDemo but simplified for our use case
This commit is contained in:
Peter Steinberger 2025-07-27 19:49:00 +02:00
parent 43e38f6b0d
commit b7aafa9a6d
3 changed files with 295 additions and 56 deletions

View file

@ -70,8 +70,15 @@ struct AutocompleteViewWithKeyboard: View {
}
}
}
.background(.regularMaterial)
.cornerRadius(6)
.background(
ZStack {
// Base opaque layer
Color(NSColor.windowBackgroundColor)
// Material overlay for visual consistency
Color.primary.opacity(0.02)
}
)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
@ -201,49 +208,64 @@ struct AutocompleteTextField: View {
@State private var justSelectedCompletion = false
@State private var keyboardNavigating = false
@State private var textFieldSize: CGSize = .zero
var body: some View {
VStack(spacing: 4) {
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
.focused($isFocused)
.onKeyPress { keyPress in
handleKeyPress(keyPress)
}
.onChange(of: text) { _, newValue in
handleTextChange(newValue)
}
.onChange(of: isFocused) { _, focused in
if !focused {
// Hide suggestions after a delay to allow clicking
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
showSuggestions = false
selectedIndex = -1
}
} else if focused && !text.isEmpty && !(autocompleteService?.suggestions.isEmpty ?? true) {
// Show suggestions when field gains focus if we have any
showSuggestions = true
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
.focused($isFocused)
.onKeyPress { keyPress in
handleKeyPress(keyPress)
}
.onChange(of: text) { _, newValue in
handleTextChange(newValue)
}
.onChange(of: isFocused) { _, focused in
if !focused {
// Hide suggestions after a delay to allow clicking
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
showSuggestions = false
selectedIndex = -1
}
} else if focused && !text.isEmpty && !(autocompleteService?.suggestions.isEmpty ?? true) {
// Show suggestions when field gains focus if we have any
showSuggestions = true
}
if showSuggestions && isFocused && !(autocompleteService?.suggestions.isEmpty ?? true) {
AutocompleteViewWithKeyboard(
}
.background(
GeometryReader { geometry in
Color.clear
.onAppear {
textFieldSize = geometry.size
}
.onChange(of: geometry.size) { _, newSize in
textFieldSize = newSize
}
}
)
.background(
AutocompleteWindowView(
suggestions: autocompleteService?.suggestions ?? [],
selectedIndex: $selectedIndex,
keyboardNavigating: keyboardNavigating
) { suggestion in
justSelectedCompletion = true
text = suggestion
showSuggestions = false
selectedIndex = -1
autocompleteService?.clearSuggestions()
}
.transition(.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95)).combined(with: .offset(y: -5)),
removal: .opacity.combined(with: .scale(scale: 0.95))
))
}
}
.animation(.easeInOut(duration: 0.15), value: showSuggestions)
keyboardNavigating: keyboardNavigating,
onSelect: { suggestion in
justSelectedCompletion = true
text = suggestion
showSuggestions = false
selectedIndex = -1
autocompleteService?.clearSuggestions()
// Keep focus on the text field
DispatchQueue.main.async {
isFocused = true
}
},
width: textFieldSize.width,
isShowing: Binding(
get: { showSuggestions && isFocused && !(autocompleteService?.suggestions.isEmpty ?? true) },
set: { showSuggestions = $0 }
)
)
)
.onAppear {
// Initialize autocompleteService with GitRepositoryMonitor
autocompleteService = AutocompleteService(gitMonitor: gitMonitor)
@ -355,3 +377,49 @@ struct AutocompleteTextField: View {
}
}
}
/// Popup content wrapper for autocomplete suggestions
private struct AutocompletePopupContent: View {
let suggestions: [PathSuggestion]
@Binding var selectedIndex: Int
let keyboardNavigating: Bool
let width: CGFloat
let onSelect: (String) -> Void
var body: some View {
AutocompleteViewWithKeyboard(
suggestions: suggestions,
selectedIndex: $selectedIndex,
keyboardNavigating: keyboardNavigating,
onSelect: onSelect
)
.frame(width: width)
.frame(maxHeight: 200)
.background(
VisualEffectBackground()
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
}
}
/// Visual effect background for the autocomplete dropdown
private struct VisualEffectBackground: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let effectView = NSVisualEffectView()
effectView.material = .popover
effectView.blendingMode = .behindWindow
effectView.state = .active
effectView.wantsLayer = true
effectView.layer?.cornerRadius = 6
effectView.layer?.masksToBounds = true
return effectView
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
// No updates needed
}
}

View file

@ -0,0 +1,172 @@
import SwiftUI
import AppKit
/// Simple NSWindow-based dropdown for autocomplete
struct AutocompleteWindowView: NSViewRepresentable {
let suggestions: [PathSuggestion]
@Binding var selectedIndex: Int
let keyboardNavigating: Bool
let onSelect: (String) -> Void
let width: CGFloat
@Binding var isShowing: Bool
func makeNSView(context: Context) -> NSView {
let view = NSView()
view.wantsLayer = true
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
if isShowing && !suggestions.isEmpty {
context.coordinator.showDropdown(
on: nsView,
suggestions: suggestions,
selectedIndex: selectedIndex,
keyboardNavigating: keyboardNavigating,
width: width
)
} else {
context.coordinator.hideDropdown()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(onSelect: onSelect, isShowing: $isShowing)
}
@MainActor
class Coordinator: NSObject {
private var dropdownWindow: NSWindow?
private var hostingView: NSHostingView<AnyView>?
private let onSelect: (String) -> Void
@Binding var isShowing: Bool
nonisolated(unsafe) private var clickMonitor: Any?
init(onSelect: @escaping (String) -> Void, isShowing: Binding<Bool>) {
self.onSelect = onSelect
self._isShowing = isShowing
super.init()
}
deinit {
if let monitor = clickMonitor {
DispatchQueue.main.async {
NSEvent.removeMonitor(monitor)
}
}
}
@MainActor
private func cleanupClickMonitor() {
if let monitor = clickMonitor {
NSEvent.removeMonitor(monitor)
clickMonitor = nil
}
}
@MainActor
func showDropdown(
on view: NSView,
suggestions: [PathSuggestion],
selectedIndex: Int,
keyboardNavigating: Bool,
width: CGFloat
) {
guard let parentWindow = view.window else { return }
// Create window if needed
if dropdownWindow == nil {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: width, height: 200),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = true
window.level = .floating
window.isReleasedWhenClosed = false
let hostingView = NSHostingView(rootView: AnyView(EmptyView()))
window.contentView = hostingView
self.dropdownWindow = window
self.hostingView = hostingView
}
guard let window = dropdownWindow,
let hostingView = hostingView else { return }
// Update content
let content = VStack(spacing: 0) {
AutocompleteViewWithKeyboard(
suggestions: suggestions,
selectedIndex: .constant(selectedIndex),
keyboardNavigating: keyboardNavigating
) { [weak self] suggestion in
self?.onSelect(suggestion)
self?.isShowing = false
}
}
.frame(width: width)
.frame(maxHeight: 200)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(NSColor.controlBackgroundColor))
.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
hostingView.rootView = AnyView(content)
// Position window below the text field
let viewFrame = view.convert(view.bounds, to: nil)
let screenFrame = parentWindow.convertToScreen(viewFrame)
// Calculate window position
let windowFrame = NSRect(
x: screenFrame.minX,
y: screenFrame.minY - 204, // dropdown height + spacing
width: width,
height: 200
)
window.setFrame(windowFrame, display: false)
// Show window
if window.parent == nil {
parentWindow.addChildWindow(window, ordered: .above)
}
window.makeKeyAndOrderFront(nil)
// Setup click monitoring
if clickMonitor == nil {
clickMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]
) { [weak self] event in
if event.window != window {
self?.isShowing = false
}
return event
}
}
}
@MainActor
func hideDropdown() {
cleanupClickMonitor()
if let window = dropdownWindow {
if let parent = window.parent {
parent.removeChildWindow(window)
}
window.orderOut(nil)
}
}
}
}

View file

@ -163,24 +163,23 @@ struct NewSessionForm: View {
.font(.system(size: 11, weight: .medium))
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
AutocompleteTextField(text: $workingDirectory, placeholder: "~/")
.focused($focusedField, equals: .directory)
.onChange(of: workingDirectory) { _, newValue in
checkForGitRepository(at: newValue)
}
Button(action: selectDirectory) {
Image(systemName: "folder")
.font(.system(size: 12))
.foregroundColor(.secondary)
.frame(width: 20, height: 20)
.contentShape(Rectangle())
HStack(spacing: 8) {
AutocompleteTextField(text: $workingDirectory, placeholder: "~/")
.focused($focusedField, equals: .directory)
.onChange(of: workingDirectory) { _, newValue in
checkForGitRepository(at: newValue)
}
.buttonStyle(.borderless)
.help("Choose directory")
.zIndex(1) // Ensure autocomplete appears above other elements
Button(action: selectDirectory) {
Image(systemName: "folder")
.font(.system(size: 12))
.foregroundColor(.secondary)
.frame(width: 20, height: 20)
.contentShape(Rectangle())
}
.buttonStyle(.borderless)
.help("Choose directory")
}
}