| | |
| | | static let globalVisible = "globalVisible" |
| | | } |
| | | |
| | | enum QuickSearchDismissSource { |
| | | static let keyboard = "keyboard" |
| | | static let mouse = "mouse" |
| | | static let backdrop = "backdrop" |
| | | static let programmatic = "programmatic" |
| | | } |
| | | |
| | | // MARK: - Hotkeys |
| | | |
| | | struct LauncherHotkey: Equatable { |
| | |
| | | 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 { |
| | |
| | | tint: .secondary |
| | | ) |
| | | } else { |
| | | ScrollViewReader { scrollProxy in |
| | | ScrollView(.vertical, showsIndicators: results.count > maxVisibleRows) { |
| | | LazyVStack(spacing: 2) { |
| | | ForEach(results) { result in |
| | | QuickSearchResultRow( |
| | | result: result, |
| | | isSelected: result.id == selectedID |
| | | ) |
| | | .frame(height: rowHeight) |
| | | .id(result.id) |
| | | .contentShape(Rectangle()) |
| | | .onHover { hovering in |
| | | if hovering { onHover(result) } |
| | | } |
| | | .onTapGesture { |
| | | onLaunch(result) |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 10) |
| | | .padding(.vertical, 10) |
| | | } |
| | | .frame(height: CGFloat(min(results.count, maxVisibleRows)) * (rowHeight + 2) + 20) |
| | | .onChange(of: selectionScrollToken) { _, _ in |
| | | guard let id = selectedID else { return } |
| | | withAnimation(.easeOut(duration: 0.08)) { |
| | | scrollProxy.scrollTo(id, anchor: .center) |
| | | } |
| | | } |
| | | } |
| | | QuickSearchResultListView( |
| | | results: results, |
| | | selectedID: selectedID, |
| | | selectionScrollToken: selectionScrollToken, |
| | | maxVisibleRows: maxVisibleRows, |
| | | rowHeight: rowHeight, |
| | | isDarkMode: colorScheme == .dark, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | .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")) |
| | | } |
| | | } |
| | | |
| | | private struct QuickSearchResultRow: View { |
| | | let result: QuickSearchResult |
| | | let isSelected: Bool |
| | | @Environment(\.colorScheme) private var colorScheme |
| | | private struct QuickSearchResultListView: NSViewRepresentable { |
| | | let results: [QuickSearchResult] |
| | | let selectedID: URL? |
| | | let selectionScrollToken: Int |
| | | let maxVisibleRows: Int |
| | | let rowHeight: CGFloat |
| | | let isDarkMode: Bool |
| | | let onHover: (QuickSearchResult) -> Void |
| | | let onLaunch: (QuickSearchResult) -> Void |
| | | |
| | | var body: some View { |
| | | HStack(spacing: 16) { |
| | | Image(nsImage: result.app.icon) |
| | | .resizable() |
| | | .frame(width: 46, height: 46) |
| | | .cornerRadius(10) |
| | | |
| | | VStack(alignment: .leading, spacing: 4) { |
| | | Text(result.app.displayName) |
| | | .font(.system(size: 20, weight: .semibold)) |
| | | .foregroundStyle(.primary) |
| | | .lineLimit(1) |
| | | .truncationMode(.tail) |
| | | |
| | | if let detailText { |
| | | Text(detailText) |
| | | .font(.system(size: 16, weight: .medium)) |
| | | .foregroundStyle(Color.primary.opacity(0.38)) |
| | | .lineLimit(1) |
| | | .truncationMode(.tail) |
| | | } |
| | | } |
| | | .layoutPriority(1) |
| | | |
| | | Spacer(minLength: 8) |
| | | |
| | | if let tagName = rightTagName { |
| | | Text(tagName) |
| | | .font(.system(size: 14, weight: .semibold)) |
| | | .foregroundStyle(Color.primary.opacity(0.46)) |
| | | .lineLimit(1) |
| | | .truncationMode(.tail) |
| | | .padding(.horizontal, 12) |
| | | .frame(height: 32) |
| | | .frame(maxWidth: 128) |
| | | .background( |
| | | Capsule(style: .continuous) |
| | | .fill(Color.primary.opacity(colorScheme == .dark ? 0.12 : 0.07)) |
| | | ) |
| | | } |
| | | } |
| | | .padding(.horizontal, 18) |
| | | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 18, style: .continuous) |
| | | .fill(isSelected ? selectedFill : Color.clear) |
| | | func makeNSView(context: Context) -> QuickSearchResultListHostView { |
| | | let view = QuickSearchResultListHostView() |
| | | view.update( |
| | | results: results, |
| | | selectedID: selectedID, |
| | | selectionScrollToken: selectionScrollToken, |
| | | maxVisibleRows: maxVisibleRows, |
| | | rowHeight: rowHeight, |
| | | isDarkMode: isDarkMode, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | .accessibilityLabel(accessibilityText) |
| | | return view |
| | | } |
| | | |
| | | private var selectedFill: Color { |
| | | colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.075) |
| | | func updateNSView(_ view: QuickSearchResultListHostView, context: Context) { |
| | | view.update( |
| | | results: results, |
| | | selectedID: selectedID, |
| | | selectionScrollToken: selectionScrollToken, |
| | | maxVisibleRows: maxVisibleRows, |
| | | rowHeight: rowHeight, |
| | | isDarkMode: isDarkMode, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | } |
| | | } |
| | | |
| | | final class QuickSearchResultListHostView: NSView { |
| | | private let scrollView = NSScrollView() |
| | | private let documentView = QuickSearchResultListDocumentView() |
| | | private var selectedID: URL? |
| | | private var selectionScrollToken = 0 |
| | | private var didInstall = false |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | private var detailText: String? { |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | func update( |
| | | results: [QuickSearchResult], |
| | | selectedID: URL?, |
| | | selectionScrollToken: Int, |
| | | maxVisibleRows: Int, |
| | | rowHeight: CGFloat, |
| | | isDarkMode: Bool, |
| | | onHover: @escaping (QuickSearchResult) -> Void, |
| | | onLaunch: @escaping (QuickSearchResult) -> Void |
| | | ) { |
| | | let shouldScrollSelection = selectionScrollToken != self.selectionScrollToken |
| | | self.selectedID = selectedID |
| | | self.selectionScrollToken = selectionScrollToken |
| | | scrollView.hasVerticalScroller = results.count > maxVisibleRows |
| | | documentView.update( |
| | | results: results, |
| | | selectedID: selectedID, |
| | | rowHeight: rowHeight, |
| | | isDarkMode: isDarkMode, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | needsLayout = true |
| | | layoutSubtreeIfNeeded() |
| | | if shouldScrollSelection, let selectedID { |
| | | scrollResultToCenter(selectedID) |
| | | } |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | scrollView.frame = bounds |
| | | let visibleSize = scrollView.contentView.bounds.size |
| | | documentView.frame = NSRect( |
| | | origin: .zero, |
| | | size: NSSize( |
| | | width: max(visibleSize.width, bounds.width), |
| | | height: documentView.preferredHeight |
| | | ) |
| | | ) |
| | | documentView.needsLayout = true |
| | | } |
| | | |
| | | private func setup() { |
| | | guard !didInstall else { return } |
| | | didInstall = true |
| | | wantsLayer = true |
| | | layer?.backgroundColor = NSColor.clear.cgColor |
| | | |
| | | scrollView.drawsBackground = false |
| | | scrollView.borderType = .noBorder |
| | | scrollView.autohidesScrollers = true |
| | | scrollView.scrollerStyle = .overlay |
| | | scrollView.hasHorizontalScroller = false |
| | | scrollView.hasVerticalScroller = false |
| | | scrollView.documentView = documentView |
| | | addSubview(scrollView) |
| | | } |
| | | |
| | | private func scrollResultToCenter(_ id: URL) { |
| | | guard let frame = documentView.frameForResult(id) else { return } |
| | | let visibleBounds = scrollView.contentView.bounds |
| | | let maxY = max(0, documentView.bounds.height - visibleBounds.height) |
| | | let targetY = min(max(0, frame.midY - visibleBounds.height / 2), maxY) |
| | | scrollView.contentView.scroll(to: NSPoint(x: 0, y: targetY)) |
| | | scrollView.reflectScrolledClipView(scrollView.contentView) |
| | | } |
| | | } |
| | | |
| | | final class QuickSearchResultListDocumentView: NSView { |
| | | private var rowViews: [QuickSearchResultRowView] = [] |
| | | private var rowIDs: [URL] = [] |
| | | private var rowHeight: CGFloat = 74 |
| | | private let rowSpacing: CGFloat = 2 |
| | | private let horizontalInset: CGFloat = 10 |
| | | private let verticalInset: CGFloat = 10 |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | var preferredHeight: CGFloat { |
| | | verticalInset * 2 |
| | | + CGFloat(rowViews.count) * rowHeight |
| | | + CGFloat(max(0, rowViews.count - 1)) * rowSpacing |
| | | } |
| | | |
| | | func update( |
| | | results: [QuickSearchResult], |
| | | selectedID: URL?, |
| | | rowHeight: CGFloat, |
| | | isDarkMode: Bool, |
| | | onHover: @escaping (QuickSearchResult) -> Void, |
| | | onLaunch: @escaping (QuickSearchResult) -> Void |
| | | ) { |
| | | self.rowHeight = rowHeight |
| | | let nextIDs = results.map(\.id) |
| | | if nextIDs != rowIDs { |
| | | rebuildRows(for: results) |
| | | rowIDs = nextIDs |
| | | } |
| | | |
| | | for (index, row) in rowViews.enumerated() { |
| | | guard index < results.count else { continue } |
| | | row.configure( |
| | | result: results[index], |
| | | isSelected: results[index].id == selectedID, |
| | | isDarkMode: isDarkMode, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | ) |
| | | } |
| | | needsLayout = true |
| | | } |
| | | |
| | | func frameForResult(_ id: URL) -> NSRect? { |
| | | guard let index = rowIDs.firstIndex(of: id), |
| | | index < rowViews.count |
| | | else { return nil } |
| | | return rowViews[index].frame |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let width = max(0, bounds.width - horizontalInset * 2) |
| | | var y = verticalInset |
| | | for row in rowViews { |
| | | row.frame = NSRect(x: horizontalInset, y: y, width: width, height: rowHeight) |
| | | y += rowHeight + rowSpacing |
| | | } |
| | | } |
| | | |
| | | private func rebuildRows(for results: [QuickSearchResult]) { |
| | | rowViews.forEach { $0.removeFromSuperview() } |
| | | rowViews = results.map { _ in |
| | | let row = QuickSearchResultRowView() |
| | | addSubview(row) |
| | | return row |
| | | } |
| | | } |
| | | } |
| | | |
| | | final class QuickSearchResultRowView: NSView { |
| | | private enum TagBadgeLayout { |
| | | static let columnWidth: CGFloat = 196 |
| | | static let minWidth: CGFloat = 80 |
| | | static let maxWidth: CGFloat = 196 |
| | | static let height: CGFloat = 32 |
| | | static let horizontalInset: CGFloat = 16 |
| | | static let labelSafetyWidth: CGFloat = 8 |
| | | static let trailingInset: CGFloat = 18 |
| | | static let textGap: CGFloat = 18 |
| | | static let minimumMainTextWidth: CGFloat = 260 |
| | | } |
| | | |
| | | private let iconView = NSImageView() |
| | | private let titleLabel = NSTextField(labelWithString: "") |
| | | private let detailLabel = NSTextField(labelWithString: "") |
| | | private let tagBackgroundView = NSView() |
| | | private let tagLabel = NSTextField(labelWithString: "") |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | private var result: QuickSearchResult? |
| | | private var isSelected = false |
| | | private var isDarkMode = false |
| | | private var onHover: (QuickSearchResult) -> Void = { _ in } |
| | | private var onLaunch: (QuickSearchResult) -> Void = { _ in } |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | func configure( |
| | | result: QuickSearchResult, |
| | | isSelected: Bool, |
| | | isDarkMode: Bool, |
| | | onHover: @escaping (QuickSearchResult) -> Void, |
| | | onLaunch: @escaping (QuickSearchResult) -> Void |
| | | ) { |
| | | self.result = result |
| | | self.isSelected = isSelected |
| | | self.isDarkMode = isDarkMode |
| | | self.onHover = onHover |
| | | self.onLaunch = onLaunch |
| | | |
| | | iconView.image = result.app.icon |
| | | titleLabel.stringValue = result.app.displayName |
| | | detailLabel.stringValue = detailText(for: result) ?? "" |
| | | detailLabel.isHidden = detailLabel.stringValue.isEmpty |
| | | tagLabel.stringValue = rightTagName(for: result) ?? "" |
| | | tagBackgroundView.isHidden = tagLabel.stringValue.isEmpty |
| | | setAccessibilityLabel(accessibilityText(for: result)) |
| | | updateColors() |
| | | needsLayout = true |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let iconSize: CGFloat = 46 |
| | | let iconX: CGFloat = 18 |
| | | iconView.frame = NSRect( |
| | | x: iconX, |
| | | y: (bounds.height - iconSize) / 2, |
| | | width: iconSize, |
| | | height: iconSize |
| | | ) |
| | | |
| | | let textX = iconX + iconSize + 16 |
| | | let trailingInset = TagBadgeLayout.trailingInset |
| | | let rightLimit = bounds.width - trailingInset |
| | | var tagFrame = NSRect.zero |
| | | let hasTag = !tagLabel.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | let availableBadgeColumnWidth = rightLimit - textX - TagBadgeLayout.minimumMainTextWidth - TagBadgeLayout.textGap |
| | | let tagColumnWidth = min(TagBadgeLayout.columnWidth, max(0, availableBadgeColumnWidth)) |
| | | let canShowTag = hasTag && tagColumnWidth >= TagBadgeLayout.minWidth |
| | | tagBackgroundView.isHidden = !canShowTag |
| | | |
| | | if canShowTag { |
| | | let measuredLabelWidth = measuredTagLabelWidth() |
| | | let measuredBadgeWidth = measuredLabelWidth |
| | | + TagBadgeLayout.horizontalInset * 2 |
| | | + TagBadgeLayout.labelSafetyWidth |
| | | let tagWidth = min( |
| | | min(TagBadgeLayout.maxWidth, tagColumnWidth), |
| | | max(TagBadgeLayout.minWidth, measuredBadgeWidth) |
| | | ) |
| | | tagFrame = NSRect( |
| | | x: rightLimit - tagWidth, |
| | | y: (bounds.height - TagBadgeLayout.height) / 2, |
| | | width: tagWidth, |
| | | height: TagBadgeLayout.height |
| | | ) |
| | | tagBackgroundView.frame = tagFrame |
| | | tagLabel.frame = NSRect( |
| | | x: TagBadgeLayout.horizontalInset, |
| | | y: 6, |
| | | width: tagWidth - TagBadgeLayout.horizontalInset * 2, |
| | | height: 20 |
| | | ) |
| | | } else { |
| | | tagBackgroundView.frame = .zero |
| | | tagLabel.frame = .zero |
| | | } |
| | | |
| | | let textRight = tagBackgroundView.isHidden ? bounds.width - trailingInset : tagFrame.minX - TagBadgeLayout.textGap |
| | | let textWidth = max(40, textRight - textX) |
| | | if detailLabel.isHidden { |
| | | titleLabel.frame = NSRect(x: textX, y: (bounds.height - 26) / 2, width: textWidth, height: 26) |
| | | detailLabel.frame = .zero |
| | | } else { |
| | | titleLabel.frame = NSRect(x: textX, y: 14, width: textWidth, height: 25) |
| | | detailLabel.frame = NSRect(x: textX, y: 42, width: textWidth, height: 21) |
| | | } |
| | | } |
| | | |
| | | private func measuredTagLabelWidth() -> CGFloat { |
| | | let font = tagLabel.font ?? NSFont.systemFont(ofSize: 14, weight: .semibold) |
| | | return ceil((tagLabel.stringValue as NSString).size(withAttributes: [.font: font]).width) |
| | | } |
| | | |
| | | override func updateTrackingAreas() { |
| | | super.updateTrackingAreas() |
| | | if let trackingAreaRef { |
| | | removeTrackingArea(trackingAreaRef) |
| | | } |
| | | let area = NSTrackingArea( |
| | | rect: bounds, |
| | | options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | | addTrackingArea(area) |
| | | trackingAreaRef = area |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | super.mouseEntered(with: event) |
| | | if let result { |
| | | onHover(result) |
| | | } |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | if let result { |
| | | DispatchQueue.main.async { [onLaunch] in |
| | | onLaunch(result) |
| | | } |
| | | } else { |
| | | super.mouseDown(with: event) |
| | | } |
| | | } |
| | | |
| | | private func setup() { |
| | | wantsLayer = true |
| | | layer?.cornerRadius = 18 |
| | | layer?.cornerCurve = .continuous |
| | | |
| | | iconView.imageScaling = .scaleProportionallyUpOrDown |
| | | iconView.wantsLayer = true |
| | | iconView.layer?.cornerRadius = 10 |
| | | iconView.layer?.cornerCurve = .continuous |
| | | iconView.layer?.masksToBounds = true |
| | | addSubview(iconView) |
| | | |
| | | titleLabel.font = NSFont.systemFont(ofSize: 20, weight: .semibold) |
| | | titleLabel.lineBreakMode = .byTruncatingTail |
| | | titleLabel.maximumNumberOfLines = 1 |
| | | titleLabel.backgroundColor = .clear |
| | | addSubview(titleLabel) |
| | | |
| | | detailLabel.font = NSFont.systemFont(ofSize: 16, weight: .medium) |
| | | detailLabel.lineBreakMode = .byTruncatingTail |
| | | detailLabel.maximumNumberOfLines = 1 |
| | | detailLabel.backgroundColor = .clear |
| | | addSubview(detailLabel) |
| | | |
| | | tagBackgroundView.wantsLayer = true |
| | | tagBackgroundView.layer?.cornerRadius = 16 |
| | | tagBackgroundView.layer?.cornerCurve = .continuous |
| | | tagBackgroundView.addSubview(tagLabel) |
| | | addSubview(tagBackgroundView) |
| | | |
| | | tagLabel.font = NSFont.systemFont(ofSize: 14, weight: .semibold) |
| | | tagLabel.alignment = .center |
| | | tagLabel.lineBreakMode = .byTruncatingTail |
| | | tagLabel.maximumNumberOfLines = 1 |
| | | tagLabel.backgroundColor = .clear |
| | | tagLabel.allowsDefaultTighteningForTruncation = true |
| | | tagLabel.cell?.usesSingleLineMode = true |
| | | tagLabel.cell?.lineBreakMode = .byTruncatingTail |
| | | } |
| | | |
| | | private func updateColors() { |
| | | layer?.backgroundColor = isSelected |
| | | ? NSColor.labelColor.withAlphaComponent(isDarkMode ? 0.14 : 0.075).cgColor |
| | | : NSColor.clear.cgColor |
| | | titleLabel.textColor = .labelColor |
| | | detailLabel.textColor = NSColor.labelColor.withAlphaComponent(0.38) |
| | | tagLabel.textColor = NSColor.labelColor.withAlphaComponent(0.46) |
| | | tagBackgroundView.layer?.backgroundColor = NSColor.labelColor |
| | | .withAlphaComponent(isDarkMode ? 0.12 : 0.07) |
| | | .cgColor |
| | | } |
| | | |
| | | private func detailText(for result: QuickSearchResult) -> String? { |
| | | if let note = result.noteSnippet, !note.isEmpty { |
| | | return note |
| | | } |
| | |
| | | return nil |
| | | } |
| | | |
| | | private var rightTagName: String? { |
| | | private func rightTagName(for result: QuickSearchResult) -> String? { |
| | | let tag = result.matchedTagName ?? result.document.tagNames.first |
| | | guard let tag, !tag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } |
| | | return tag |
| | | } |
| | | |
| | | private var accessibilityText: String { |
| | | [result.app.displayName, detailText].compactMap { $0 }.joined(separator: ", ") |
| | | private func accessibilityText(for result: QuickSearchResult) -> String { |
| | | [result.app.displayName, detailText(for: result)].compactMap { $0 }.joined(separator: ", ") |
| | | } |
| | | } |
| | | |
| | |
| | | field.placeholderString = placeholder |
| | | field.delegate = context.coordinator |
| | | field.onCommand = onCommand |
| | | context.coordinator.onCommand = onCommand |
| | | field.setAccessibilityLabel(tr("quickSearch.inputAccessibility")) |
| | | context.coordinator.field = field |
| | | requestFocus(field) |
| | |
| | | } |
| | | field.placeholderString = placeholder |
| | | field.onCommand = onCommand |
| | | context.coordinator.onCommand = onCommand |
| | | if context.coordinator.lastFocusToken != focusToken { |
| | | context.coordinator.lastFocusToken = focusToken |
| | | requestFocus(field) |
| | |
| | | } |
| | | |
| | | 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) |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | final class Coordinator: NSObject, NSTextFieldDelegate { |
| | | var text: Binding<String> |
| | | var lastFocusToken = 0 |
| | | var onCommand: ((QuickSearchCommand) -> Void)? |
| | | weak var field: NSTextField? |
| | | |
| | | init(text: Binding<String>) { |
| | |
| | | guard let field = obj.object as? NSTextField else { return } |
| | | text.wrappedValue = field.stringValue |
| | | } |
| | | |
| | | func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { |
| | | switch commandSelector { |
| | | case #selector(NSResponder.moveUp(_:)): |
| | | onCommand?(.moveUp) |
| | | return true |
| | | case #selector(NSResponder.moveDown(_:)): |
| | | onCommand?(.moveDown) |
| | | return true |
| | | case #selector(NSResponder.insertNewline(_:)): |
| | | onCommand?(.submit) |
| | | return true |
| | | case #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)): |
| | | onCommand?(.submit) |
| | | return true |
| | | case #selector(NSResponder.cancelOperation(_:)): |
| | | onCommand?(.dismiss) |
| | | return true |
| | | default: |
| | | return false |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | 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: |