| | |
| | | case dismiss |
| | | } |
| | | |
| | | enum QuickSearchPanelMetrics { |
| | | static let width: CGFloat = 760 |
| | | static let shadowOutset: CGFloat = 56 |
| | | static let rowHeight: CGFloat = 74 |
| | | static let rowSpacing: CGFloat = 2 |
| | | static let resultListVerticalInset: CGFloat = 10 |
| | | static let headerHeight: CGFloat = 84 |
| | | static let dividerHeight: CGFloat = 1 |
| | | static let messageRowHeight: CGFloat = 86 |
| | | |
| | | static func contentHeight(hasResultList: Bool, visibleRows: Int) -> CGFloat { |
| | | if hasResultList { |
| | | return headerHeight |
| | | + dividerHeight |
| | | + CGFloat(max(1, visibleRows)) * (rowHeight + rowSpacing) |
| | | + resultListVerticalInset * 2 |
| | | } |
| | | return headerHeight + dividerHeight + messageRowHeight |
| | | } |
| | | } |
| | | |
| | | struct QuickSearchPanelPresentationView: NSViewRepresentable { |
| | | @Binding var query: String |
| | | let results: [QuickSearchResult] |
| | | let selectedID: URL? |
| | | let focusToken: Int |
| | | let selectionScrollToken: Int |
| | | let isLoading: Bool |
| | | let maxVisibleRows: Int |
| | | let panelTopY: CGFloat |
| | | let panelHeight: CGFloat |
| | | let errorMessage: String? |
| | | let onCommand: (QuickSearchCommand) -> Void |
| | | let onHover: (QuickSearchResult) -> Void |
| | | let onLaunch: (QuickSearchResult) -> Void |
| | | |
| | | func makeNSView(context: Context) -> QuickSearchPanelAnchorView { |
| | | QuickSearchPanelAnchorView() |
| | | } |
| | | |
| | | func updateNSView(_ view: QuickSearchPanelAnchorView, context: Context) { |
| | | let contentSize = NSSize(width: QuickSearchPanelMetrics.width, height: panelHeight) |
| | | let windowSize = NSSize( |
| | | width: contentSize.width + QuickSearchPanelMetrics.shadowOutset * 2, |
| | | height: contentSize.height + QuickSearchPanelMetrics.shadowOutset * 2 |
| | | ) |
| | | let rootView = QuickSearchPanelWindowContent( |
| | | windowSize: windowSize, |
| | | contentSize: contentSize, |
| | | content: AnyView( |
| | | QuickSearchOverlayView( |
| | | query: $query, |
| | | results: results, |
| | | selectedID: selectedID, |
| | | focusToken: focusToken, |
| | | selectionScrollToken: selectionScrollToken, |
| | | isLoading: isLoading, |
| | | maxVisibleRows: maxVisibleRows, |
| | | errorMessage: errorMessage, |
| | | onCommand: onCommand, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | ) |
| | | ) |
| | | context.coordinator.apply( |
| | | QuickSearchPanelConfiguration( |
| | | panelTopY: panelTopY, |
| | | windowSize: windowSize, |
| | | contentSize: contentSize, |
| | | rootView: AnyView(rootView) |
| | | ), |
| | | anchorView: view |
| | | ) |
| | | } |
| | | |
| | | static func dismantleNSView(_ nsView: QuickSearchPanelAnchorView, coordinator: Coordinator) { |
| | | coordinator.closePanel() |
| | | } |
| | | |
| | | func makeCoordinator() -> Coordinator { |
| | | Coordinator() |
| | | } |
| | | |
| | | final class Coordinator { |
| | | private var panel: QuickSearchPanel? |
| | | private var hostingView: NSHostingView<AnyView>? |
| | | private weak var parentWindow: NSWindow? |
| | | private var deferredApplyPending = false |
| | | |
| | | func apply(_ configuration: QuickSearchPanelConfiguration, anchorView: QuickSearchPanelAnchorView) { |
| | | guard let parentWindow = anchorView.window else { |
| | | scheduleDeferredApply(configuration, anchorView: anchorView) |
| | | return |
| | | } |
| | | |
| | | deferredApplyPending = false |
| | | let panel = ensurePanel(parentWindow: parentWindow) |
| | | let frame = windowFrame( |
| | | parentWindow: parentWindow, |
| | | panelTopY: configuration.panelTopY, |
| | | contentSize: configuration.contentSize, |
| | | windowSize: configuration.windowSize |
| | | ) |
| | | |
| | | hostingView?.rootView = configuration.rootView |
| | | hostingView?.frame = NSRect(origin: .zero, size: configuration.windowSize) |
| | | panel.contentView?.frame = NSRect(origin: .zero, size: configuration.windowSize) |
| | | panel.collectionBehavior = collectionBehavior(parentWindow: parentWindow) |
| | | panel.level = parentWindow.level |
| | | |
| | | if panel.parent !== parentWindow { |
| | | if let previousParent = panel.parent { |
| | | previousParent.removeChildWindow(panel) |
| | | } |
| | | parentWindow.addChildWindow(panel, ordered: .above) |
| | | self.parentWindow = parentWindow |
| | | } |
| | | |
| | | if panel.frame != frame { |
| | | panel.setFrame(frame, display: true) |
| | | } |
| | | panel.makeKeyAndOrderFront(nil) |
| | | panel.orderFrontRegardless() |
| | | } |
| | | |
| | | func closePanel() { |
| | | if let panel { |
| | | if let parent = panel.parent { |
| | | parent.removeChildWindow(panel) |
| | | } |
| | | panel.orderOut(nil) |
| | | } |
| | | panel = nil |
| | | hostingView = nil |
| | | parentWindow = nil |
| | | deferredApplyPending = false |
| | | } |
| | | |
| | | private func ensurePanel(parentWindow: NSWindow) -> QuickSearchPanel { |
| | | if let panel { |
| | | return panel |
| | | } |
| | | |
| | | let panel = QuickSearchPanel( |
| | | contentRect: .zero, |
| | | styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], |
| | | backing: .buffered, |
| | | defer: false |
| | | ) |
| | | panel.isFloatingPanel = true |
| | | panel.hidesOnDeactivate = false |
| | | panel.isOpaque = false |
| | | panel.backgroundColor = .clear |
| | | panel.hasShadow = false |
| | | panel.titlebarAppearsTransparent = true |
| | | panel.titleVisibility = .hidden |
| | | panel.isReleasedWhenClosed = false |
| | | panel.collectionBehavior = collectionBehavior(parentWindow: parentWindow) |
| | | |
| | | let hostingView = NSHostingView(rootView: AnyView(EmptyView())) |
| | | hostingView.wantsLayer = true |
| | | hostingView.layer?.backgroundColor = NSColor.clear.cgColor |
| | | panel.contentView = hostingView |
| | | |
| | | self.panel = panel |
| | | self.hostingView = hostingView |
| | | return panel |
| | | } |
| | | |
| | | private func scheduleDeferredApply( |
| | | _ configuration: QuickSearchPanelConfiguration, |
| | | anchorView: QuickSearchPanelAnchorView |
| | | ) { |
| | | guard !deferredApplyPending else { return } |
| | | deferredApplyPending = true |
| | | DispatchQueue.main.async { [weak self, weak anchorView] in |
| | | guard let self, let anchorView else { return } |
| | | self.deferredApplyPending = false |
| | | self.apply(configuration, anchorView: anchorView) |
| | | } |
| | | } |
| | | |
| | | private func windowFrame( |
| | | parentWindow: NSWindow, |
| | | panelTopY: CGFloat, |
| | | contentSize: NSSize, |
| | | windowSize: NSSize |
| | | ) -> NSRect { |
| | | let parentFrame = parentWindow.frame |
| | | return NSRect( |
| | | x: parentFrame.minX + (parentFrame.width - contentSize.width) / 2 - QuickSearchPanelMetrics.shadowOutset, |
| | | y: parentFrame.maxY - panelTopY - contentSize.height - QuickSearchPanelMetrics.shadowOutset, |
| | | width: windowSize.width, |
| | | height: windowSize.height |
| | | ) |
| | | } |
| | | |
| | | private func collectionBehavior(parentWindow: NSWindow) -> NSWindow.CollectionBehavior { |
| | | var behavior: NSWindow.CollectionBehavior = [ |
| | | .fullScreenAuxiliary, |
| | | .stationary, |
| | | .transient, |
| | | .ignoresCycle |
| | | ] |
| | | if parentWindow.collectionBehavior.contains(.canJoinAllSpaces) { |
| | | behavior.insert(.canJoinAllSpaces) |
| | | } |
| | | return behavior |
| | | } |
| | | } |
| | | } |
| | | |
| | | private final class QuickSearchPanel: NSPanel { |
| | | override var canBecomeKey: Bool { true } |
| | | override var canBecomeMain: Bool { false } |
| | | |
| | | override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { |
| | | frameRect |
| | | } |
| | | } |
| | | |
| | | final class QuickSearchPanelAnchorView: NSView { |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | private func setup() { |
| | | wantsLayer = true |
| | | layer?.backgroundColor = NSColor.clear.cgColor |
| | | } |
| | | } |
| | | |
| | | struct QuickSearchPanelConfiguration { |
| | | let panelTopY: CGFloat |
| | | let windowSize: NSSize |
| | | let contentSize: NSSize |
| | | let rootView: AnyView |
| | | } |
| | | |
| | | private struct QuickSearchPanelWindowContent: View { |
| | | let windowSize: NSSize |
| | | let contentSize: NSSize |
| | | let content: AnyView |
| | | |
| | | var body: some View { |
| | | VStack(spacing: 0) { |
| | | content |
| | | .frame(width: contentSize.width, height: contentSize.height, alignment: .top) |
| | | Spacer(minLength: 0) |
| | | } |
| | | .padding(.top, QuickSearchPanelMetrics.shadowOutset) |
| | | .padding(.horizontal, QuickSearchPanelMetrics.shadowOutset) |
| | | .padding(.bottom, QuickSearchPanelMetrics.shadowOutset) |
| | | .frame(width: windowSize.width, height: windowSize.height, alignment: .top) |
| | | .background(Color.clear) |
| | | } |
| | | } |
| | | |
| | | struct QuickSearchOverlayView: View { |
| | | @Binding var query: String |
| | | let results: [QuickSearchResult] |
| | |
| | | |
| | | @Environment(\.colorScheme) private var colorScheme |
| | | |
| | | private let panelWidth: CGFloat = 760 |
| | | private let rowHeight: CGFloat = 74 |
| | | private let panelWidth: CGFloat = QuickSearchPanelMetrics.width |
| | | private let rowHeight: CGFloat = QuickSearchPanelMetrics.rowHeight |
| | | |
| | | private var panelBackgroundColor: Color { |
| | | colorScheme == .dark |
| | | ? Color(red: 0.105, green: 0.110, blue: 0.125).opacity(0.97) |
| | | : Color.white.opacity(0.97) |
| | | } |
| | | |
| | | private var panelShape: RoundedRectangle { |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | } |
| | | |
| | | var body: some View { |
| | |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20) |
| | | .frame( |
| | | height: CGFloat(min(results.count, maxVisibleRows)) |
| | | * (rowHeight + QuickSearchPanelMetrics.rowSpacing) |
| | | + QuickSearchPanelMetrics.resultListVerticalInset * 2 |
| | | ) |
| | | } |
| | | } |
| | | .frame(width: panelWidth) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | .fill(panelBackgroundColor) |
| | | .shadow(color: .black.opacity(0.18), radius: 36, y: 18) |
| | | panelShape.fill(panelBackgroundColor) |
| | | ) |
| | | .clipShape(panelShape) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | .stroke(Color.primary.opacity(0.10), lineWidth: 1) |
| | | panelShape.stroke(Color.primary.opacity(0.10), lineWidth: 1) |
| | | ) |
| | | .compositingGroup() |
| | | .shadow(color: .black.opacity(colorScheme == .dark ? 0.46 : 0.26), radius: 30, x: 0, y: 18) |
| | | .shadow(color: .black.opacity(colorScheme == .dark ? 0.24 : 0.12), radius: 8, x: 0, y: 3) |
| | | .accessibilityElement(children: .contain) |
| | | .accessibilityLabel(tr("quickSearch.title")) |
| | | } |
| | |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | if let result { |
| | | onLaunch(result) |
| | | DispatchQueue.main.async { [onLaunch] in |
| | | onLaunch(result) |
| | | } |
| | | } else { |
| | | super.mouseDown(with: event) |
| | | } |
| | |
| | | } |
| | | |
| | | private func requestFocus(_ field: QuickSearchNativeTextField) { |
| | | DispatchQueue.main.async { [weak field] in |
| | | guard let field, |
| | | field.window?.firstResponder !== field |
| | | else { return } |
| | | field.window?.makeFirstResponder(field) |
| | | for delay in [0.0, 0.03, 0.08, 0.16] { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak field] in |
| | | guard let field, |
| | | let window = field.window, |
| | | window.isVisible, |
| | | window.firstResponder !== field |
| | | else { return } |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | window.makeFirstResponder(field) |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | private final class QuickSearchNativeTextField: NSTextField { |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | |
| | | override var acceptsFirstResponder: Bool { true } |
| | | |
| | | override func keyDown(with event: NSEvent) { |
| | | switch Int(event.keyCode) { |
| | | case kVK_UpArrow: |