From b7aafa9a6d5da41e1d2b5f9d830ca46995398fef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 27 Jul 2025 19:49:00 +0200 Subject: [PATCH] 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 --- .../Components/AutocompleteView.swift | 148 +++++++++++---- .../Components/AutocompleteWindow.swift | 172 ++++++++++++++++++ .../Components/NewSessionForm.swift | 31 ++-- 3 files changed, 295 insertions(+), 56 deletions(-) create mode 100644 mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift diff --git a/mac/VibeTunnel/Presentation/Components/AutocompleteView.swift b/mac/VibeTunnel/Presentation/Components/AutocompleteView.swift index 7699a874..aeb28a61 100644 --- a/mac/VibeTunnel/Presentation/Components/AutocompleteView.swift +++ b/mac/VibeTunnel/Presentation/Components/AutocompleteView.swift @@ -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 + } +} diff --git a/mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift b/mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift new file mode 100644 index 00000000..397a275c --- /dev/null +++ b/mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift @@ -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? + private let onSelect: (String) -> Void + @Binding var isShowing: Bool + nonisolated(unsafe) private var clickMonitor: Any? + + init(onSelect: @escaping (String) -> Void, isShowing: Binding) { + 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) + } + } + } +} \ No newline at end of file diff --git a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift index 0841d010..417bc626 100644 --- a/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift +++ b/mac/VibeTunnel/Presentation/Components/NewSessionForm.swift @@ -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") } }