mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
43e38f6b0d
commit
b7aafa9a6d
3 changed files with 295 additions and 56 deletions
|
|
@ -70,8 +70,15 @@ struct AutocompleteViewWithKeyboard: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(.regularMaterial)
|
.background(
|
||||||
.cornerRadius(6)
|
ZStack {
|
||||||
|
// Base opaque layer
|
||||||
|
Color(NSColor.windowBackgroundColor)
|
||||||
|
// Material overlay for visual consistency
|
||||||
|
Color.primary.opacity(0.02)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
||||||
|
|
@ -201,49 +208,64 @@ struct AutocompleteTextField: View {
|
||||||
@State private var justSelectedCompletion = false
|
@State private var justSelectedCompletion = false
|
||||||
@State private var keyboardNavigating = false
|
@State private var keyboardNavigating = false
|
||||||
|
|
||||||
|
@State private var textFieldSize: CGSize = .zero
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 4) {
|
TextField(placeholder, text: $text)
|
||||||
TextField(placeholder, text: $text)
|
.textFieldStyle(.roundedBorder)
|
||||||
.textFieldStyle(.roundedBorder)
|
.focused($isFocused)
|
||||||
.focused($isFocused)
|
.onKeyPress { keyPress in
|
||||||
.onKeyPress { keyPress in
|
handleKeyPress(keyPress)
|
||||||
handleKeyPress(keyPress)
|
}
|
||||||
}
|
.onChange(of: text) { _, newValue in
|
||||||
.onChange(of: text) { _, newValue in
|
handleTextChange(newValue)
|
||||||
handleTextChange(newValue)
|
}
|
||||||
}
|
.onChange(of: isFocused) { _, focused in
|
||||||
.onChange(of: isFocused) { _, focused in
|
if !focused {
|
||||||
if !focused {
|
// Hide suggestions after a delay to allow clicking
|
||||||
// Hide suggestions after a delay to allow clicking
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
showSuggestions = false
|
||||||
showSuggestions = false
|
selectedIndex = -1
|
||||||
selectedIndex = -1
|
|
||||||
}
|
|
||||||
} else if focused && !text.isEmpty && !(autocompleteService?.suggestions.isEmpty ?? true) {
|
|
||||||
// Show suggestions when field gains focus if we have any
|
|
||||||
showSuggestions = true
|
|
||||||
}
|
}
|
||||||
|
} 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) {
|
.background(
|
||||||
AutocompleteViewWithKeyboard(
|
GeometryReader { geometry in
|
||||||
|
Color.clear
|
||||||
|
.onAppear {
|
||||||
|
textFieldSize = geometry.size
|
||||||
|
}
|
||||||
|
.onChange(of: geometry.size) { _, newSize in
|
||||||
|
textFieldSize = newSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
AutocompleteWindowView(
|
||||||
suggestions: autocompleteService?.suggestions ?? [],
|
suggestions: autocompleteService?.suggestions ?? [],
|
||||||
selectedIndex: $selectedIndex,
|
selectedIndex: $selectedIndex,
|
||||||
keyboardNavigating: keyboardNavigating
|
keyboardNavigating: keyboardNavigating,
|
||||||
) { suggestion in
|
onSelect: { suggestion in
|
||||||
justSelectedCompletion = true
|
justSelectedCompletion = true
|
||||||
text = suggestion
|
text = suggestion
|
||||||
showSuggestions = false
|
showSuggestions = false
|
||||||
selectedIndex = -1
|
selectedIndex = -1
|
||||||
autocompleteService?.clearSuggestions()
|
autocompleteService?.clearSuggestions()
|
||||||
}
|
// Keep focus on the text field
|
||||||
.transition(.asymmetric(
|
DispatchQueue.main.async {
|
||||||
insertion: .opacity.combined(with: .scale(scale: 0.95)).combined(with: .offset(y: -5)),
|
isFocused = true
|
||||||
removal: .opacity.combined(with: .scale(scale: 0.95))
|
}
|
||||||
))
|
},
|
||||||
}
|
width: textFieldSize.width,
|
||||||
}
|
isShowing: Binding(
|
||||||
.animation(.easeInOut(duration: 0.15), value: showSuggestions)
|
get: { showSuggestions && isFocused && !(autocompleteService?.suggestions.isEmpty ?? true) },
|
||||||
|
set: { showSuggestions = $0 }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Initialize autocompleteService with GitRepositoryMonitor
|
// Initialize autocompleteService with GitRepositoryMonitor
|
||||||
autocompleteService = AutocompleteService(gitMonitor: gitMonitor)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
172
mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift
Normal file
172
mac/VibeTunnel/Presentation/Components/AutocompleteWindow.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -163,24 +163,23 @@ struct NewSessionForm: View {
|
||||||
.font(.system(size: 11, weight: .medium))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
AutocompleteTextField(text: $workingDirectory, placeholder: "~/")
|
||||||
AutocompleteTextField(text: $workingDirectory, placeholder: "~/")
|
.focused($focusedField, equals: .directory)
|
||||||
.focused($focusedField, equals: .directory)
|
.onChange(of: workingDirectory) { _, newValue in
|
||||||
.onChange(of: workingDirectory) { _, newValue in
|
checkForGitRepository(at: newValue)
|
||||||
checkForGitRepository(at: newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(action: selectDirectory) {
|
|
||||||
Image(systemName: "folder")
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.zIndex(1) // Ensure autocomplete appears above other elements
|
||||||
.help("Choose directory")
|
|
||||||
|
Button(action: selectDirectory) {
|
||||||
|
Image(systemName: "folder")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Choose directory")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue