mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-12 12:25:53 +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)
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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))
|
||||
.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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue