diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 9e3f824bc..272908288 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -586,6 +586,7 @@ B6CFD8112C20A8EE00E63F1A /* NSFont+WithWeight.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6CFD8102C20A8EE00E63F1A /* NSFont+WithWeight.swift */; }; B6D7EA592971078500301FAC /* InspectorSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D7EA582971078500301FAC /* InspectorSection.swift */; }; B6D7EA5C297107DD00301FAC /* InspectorField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D7EA5B297107DD00301FAC /* InspectorField.swift */; }; + B6DCDAC62CCDE2B90099FBF9 /* InstantPopoverModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DCDAC52CCDE2B90099FBF9 /* InstantPopoverModifier.swift */; }; B6E41C7029DD157F0088F9F4 /* AccountsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C6F29DD157F0088F9F4 /* AccountsSettingsView.swift */; }; B6E41C7429DD40010088F9F4 /* View+HideSidebarToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C7329DD40010088F9F4 /* View+HideSidebarToggle.swift */; }; B6E41C7929DE02800088F9F4 /* AccountSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E41C7829DE02800088F9F4 /* AccountSelectionView.swift */; }; @@ -1250,6 +1251,7 @@ B6CFD8102C20A8EE00E63F1A /* NSFont+WithWeight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSFont+WithWeight.swift"; sourceTree = ""; }; B6D7EA582971078500301FAC /* InspectorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorSection.swift; sourceTree = ""; }; B6D7EA5B297107DD00301FAC /* InspectorField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorField.swift; sourceTree = ""; }; + B6DCDAC52CCDE2B90099FBF9 /* InstantPopoverModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstantPopoverModifier.swift; sourceTree = ""; }; B6E41C6F29DD157F0088F9F4 /* AccountsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsSettingsView.swift; sourceTree = ""; }; B6E41C7329DD40010088F9F4 /* View+HideSidebarToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+HideSidebarToggle.swift"; sourceTree = ""; }; B6E41C7829DE02800088F9F4 /* AccountSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSelectionView.swift; sourceTree = ""; }; @@ -2068,6 +2070,7 @@ 6CBA0D502A1BF524002C6FAA /* SegmentedControlImproved.swift */, 587B9D8C29300ABD00AC7927 /* SettingsTextEditor.swift */, 587B9D8F29300ABD00AC7927 /* ToolbarBranchPicker.swift */, + B6DCDAC52CCDE2B90099FBF9 /* InstantPopoverModifier.swift */, 2897E1C62979A29200741E32 /* TrackableScrollView.swift */, B60718302B15A9A3009CDAB4 /* CEOutlineGroup.swift */, ); @@ -4032,6 +4035,7 @@ 6CFF967429BEBCC300182D6F /* FindCommands.swift in Sources */, 587B9E6529301D8F00AC7927 /* GitLabGroupAccess.swift in Sources */, 6C91D57229B176FF0059A90D /* EditorManager.swift in Sources */, + B6DCDAC62CCDE2B90099FBF9 /* InstantPopoverModifier.swift in Sources */, 6C82D6BC29C00CD900495C54 /* FirstResponderPropertyWrapper.swift in Sources */, 58D01C9B293167DC00C5B6B4 /* CodeEditKeychainConstants.swift in Sources */, B640A99E29E2184700715F20 /* SettingsForm.swift in Sources */, diff --git a/CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift b/CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift index 29c60a461..80e51d0d6 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift +++ b/CodeEdit/Features/ActivityViewer/Tasks/SchemeDropDownView.swift @@ -53,7 +53,7 @@ struct SchemeDropDownView: View { .onHover(perform: { hovering in self.isHoveringScheme = hovering }) - .popover(isPresented: $isSchemePopOverPresented, arrowEdge: .bottom) { + .instantPopover(isPresented: $isSchemePopOverPresented, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 0) { WorkspaceMenuItemView( workspaceFileManager: workspaceFileManager, diff --git a/CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift b/CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift index 72de62545..2382cbec0 100644 --- a/CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift +++ b/CodeEdit/Features/ActivityViewer/Tasks/TaskDropDownView.swift @@ -38,7 +38,7 @@ struct TaskDropDownView: View { .onHover { hovering in self.isHoveringTasks = hovering } - .popover(isPresented: $isTaskPopOverPresented, arrowEdge: .bottom) { + .instantPopover(isPresented: $isTaskPopOverPresented, arrowEdge: .bottom) { taskPopoverContent } .onTapGesture { diff --git a/CodeEdit/Features/CodeEditUI/Views/InstantPopoverModifier.swift b/CodeEdit/Features/CodeEditUI/Views/InstantPopoverModifier.swift new file mode 100644 index 000000000..0c2b86d0d --- /dev/null +++ b/CodeEdit/Features/CodeEditUI/Views/InstantPopoverModifier.swift @@ -0,0 +1,132 @@ +// +// InstantPopoverModifier.swift +// CodeEdit +// +// Created by Kihron on 10/26/24. +// + +import SwiftUI + +struct InstantPopoverModifier: ViewModifier { + @Binding var isPresented: Bool + let arrowEdge: Edge + let popoverContent: PopoverContent + + func body(content: Content) -> some View { + content + .background( + PopoverPresenter( + isPresented: $isPresented, + arrowEdge: arrowEdge, + contentView: popoverContent + ) + ) + } +} + +struct PopoverPresenter: NSViewRepresentable { + @Binding var isPresented: Bool + let arrowEdge: Edge + let contentView: ContentView + + func makeNSView(context: Context) -> NSView { NSView() } + + func updateNSView(_ nsView: NSView, context: Context) { + if isPresented, context.coordinator.popover == nil { + let popover = NSPopover() + popover.animates = false + let hostingController = NSHostingController(rootView: contentView) + + hostingController.view.layoutSubtreeIfNeeded() + let contentSize = hostingController.view.fittingSize + popover.contentSize = contentSize + + popover.contentViewController = hostingController + popover.delegate = context.coordinator + popover.behavior = .semitransient + + let nsRectEdge = edgeToNSRectEdge(arrowEdge) + popover.show(relativeTo: nsView.bounds, of: nsView, preferredEdge: nsRectEdge) + context.coordinator.popover = popover + + if let parentWindow = nsView.window { + context.coordinator.startObservingWindow(parentWindow) + } + } else if !isPresented, let popover = context.coordinator.popover { + popover.close() + context.coordinator.popover = nil + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + var popover: NSPopover? + + init(isPresented: Binding) { + _isPresented = isPresented + super.init() + } + + func startObservingWindow(_ window: NSWindow) { + /// Observe when the window loses focus + NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + guard let self = self else { return } + /// The parent window is no longer focused, close the popover + DispatchQueue.main.async { + self.isPresented = false + self.popover?.close() + } + } + } + + func popoverWillClose(_ notification: Notification) { + DispatchQueue.main.async { + self.isPresented = false + } + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + } + } + + private func edgeToNSRectEdge(_ edge: Edge) -> NSRectEdge { + switch edge { + case .top: return .minY + case .leading: return .minX + case .bottom: return .maxY + case .trailing: return .maxX + } + } +} + +extension View { + + /// A custom view modifier that presents a popover attached to the view with no animation. + /// - Parameters: + /// - isPresented: A binding to whether the popover is presented. + /// - arrowEdge: The edge of the view that the popover points to. Defaults to `.bottom`. + /// - content: A closure returning the content of the popover. + /// - Returns: A view that presents a popover when `isPresented` is `true`. + func instantPopover( + isPresented: Binding, + arrowEdge: Edge = .bottom, + @ViewBuilder content: () -> Content + ) -> some View { + self.modifier( + InstantPopoverModifier( + isPresented: isPresented, + arrowEdge: arrowEdge, + popoverContent: content() + ) + ) + } +} diff --git a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorItemView.swift b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorItemView.swift index faa073143..96bf256a2 100644 --- a/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorItemView.swift +++ b/CodeEdit/Features/InspectorArea/HistoryInspector/HistoryInspectorItemView.swift @@ -18,7 +18,7 @@ struct HistoryInspectorItemView: View { } set: { newValue in if newValue { selection = commit - } else { + } else if selection == commit { selection = nil } } @@ -26,7 +26,7 @@ struct HistoryInspectorItemView: View { var body: some View { CommitListItemView(commit: commit, showRef: false) - .popover(isPresented: showPopup, arrowEdge: .leading) { + .instantPopover(isPresented: showPopup, arrowEdge: .leading) { HistoryPopoverView(commit: commit) } }