| | |
| | | var app: AppInfo { document.app } |
| | | } |
| | | |
| | | struct QuickSearchTagFilterOption: Identifiable, Equatable { |
| | | var id: String { name } |
| | | let name: String |
| | | let colorIndex: Int |
| | | let customColor: TagCustomColor? |
| | | } |
| | | |
| | | enum QuickSearchEngine { |
| | | static func makeDocuments(apps: [AppInfo], store: TagDatabase.Store) -> [QuickSearchDocument] { |
| | | apps |
| | |
| | | } |
| | | } |
| | | |
| | | static func search(_ query: String, documents: [QuickSearchDocument], limit: Int = 50) -> [QuickSearchResult] { |
| | | static func search( |
| | | _ query: String, |
| | | documents: [QuickSearchDocument], |
| | | selectedTagName: String? = nil, |
| | | limit: Int = 50 |
| | | ) -> [QuickSearchResult] { |
| | | let activeTagName = normalizedSelectedTagName(selectedTagName) |
| | | let scopedDocuments = activeTagName.map { tagName in |
| | | documents.filter { document in |
| | | document.tagNames.contains(tagName) |
| | | } |
| | | } ?? documents |
| | | let normalizedQuery = normalizeQuery(query) |
| | | guard !normalizedQuery.isEmpty else { |
| | | return emptyQueryResults(documents: documents, limit: min(limit, 6)) |
| | | if let activeTagName { |
| | | return tagFilteredEmptyQueryResults( |
| | | documents: scopedDocuments, |
| | | selectedTagName: activeTagName, |
| | | limit: limit |
| | | ) |
| | | } |
| | | return emptyQueryResults(documents: scopedDocuments, limit: min(limit, 6)) |
| | | } |
| | | |
| | | let tokens = normalizedQuery.split(separator: " ").map(String.init) |
| | | let results = documents.compactMap { result(for: $0, tokens: tokens) } |
| | | let results = scopedDocuments.compactMap { |
| | | result(for: $0, tokens: tokens, selectedTagName: activeTagName) |
| | | } |
| | | return results.sorted(by: rank).prefix(limit).map { $0 } |
| | | } |
| | | |
| | |
| | | .lowercased() |
| | | } |
| | | |
| | | private static func result(for document: QuickSearchDocument, tokens: [String]) -> QuickSearchResult? { |
| | | private static func result( |
| | | for document: QuickSearchDocument, |
| | | tokens: [String], |
| | | selectedTagName: String? |
| | | ) -> QuickSearchResult? { |
| | | let fields = document.searchableFields |
| | | var textScore: Double = 0 |
| | | var bestFieldRank = Int.max |
| | |
| | | finalScore: finalScore, |
| | | textScore: textScore, |
| | | bestFieldRank: bestFieldRank, |
| | | matchedTagName: matchedTagName, |
| | | matchedTagName: matchedTagName ?? selectedTagName, |
| | | noteSnippet: noteSnippet |
| | | ) |
| | | } |
| | |
| | | return nil |
| | | } |
| | | |
| | | private static func tagFilteredEmptyQueryResults( |
| | | documents: [QuickSearchDocument], |
| | | selectedTagName: String, |
| | | limit: Int |
| | | ) -> [QuickSearchResult] { |
| | | documents |
| | | .map { |
| | | QuickSearchResult( |
| | | document: $0, |
| | | finalScore: behaviorBoost(for: $0), |
| | | textScore: 0, |
| | | bestFieldRank: Int.max, |
| | | matchedTagName: selectedTagName, |
| | | noteSnippet: nil |
| | | ) |
| | | } |
| | | .sorted(by: rank) |
| | | .prefix(limit) |
| | | .map { $0 } |
| | | } |
| | | |
| | | private static func emptyQueryResults(documents: [QuickSearchDocument], limit: Int) -> [QuickSearchResult] { |
| | | var used = Set<URL>() |
| | | let recent = documents |
| | |
| | | return normalizeField(String(parts)) |
| | | } |
| | | |
| | | private static func normalizedSelectedTagName(_ value: String?) -> String? { |
| | | guard let value else { return nil } |
| | | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | return trimmed.isEmpty ? nil : trimmed |
| | | } |
| | | |
| | | private static func internalBundleNames(for app: AppInfo) -> [String] { |
| | | guard let bundle = Bundle(url: app.path) else { return [] } |
| | | let values = [ |
| | |
| | | static let rowSpacing: CGFloat = 2 |
| | | static let resultListVerticalInset: CGFloat = 10 |
| | | static let headerHeight: CGFloat = 84 |
| | | static let tagFilterBarHeight: CGFloat = 40 |
| | | static let dividerHeight: CGFloat = 1 |
| | | static let messageRowHeight: CGFloat = 86 |
| | | |
| | | static func contentHeight(hasResultList: Bool, visibleRows: Int) -> CGFloat { |
| | | static func contentHeight(hasTagFilters: Bool, hasResultList: Bool, visibleRows: Int) -> CGFloat { |
| | | let tagFilterHeight = hasTagFilters ? tagFilterBarHeight : 0 |
| | | if hasResultList { |
| | | return headerHeight |
| | | + tagFilterHeight |
| | | + dividerHeight |
| | | + CGFloat(max(1, visibleRows)) * (rowHeight + rowSpacing) |
| | | + resultListVerticalInset * 2 |
| | | } |
| | | return headerHeight + dividerHeight + messageRowHeight |
| | | return headerHeight + tagFilterHeight + dividerHeight + messageRowHeight |
| | | } |
| | | } |
| | | |
| | | struct QuickSearchPanelPresentationView: NSViewRepresentable { |
| | | @Binding var query: String |
| | | let results: [QuickSearchResult] |
| | | let tagFilters: [QuickSearchTagFilterOption] |
| | | let selectedTagName: String? |
| | | let selectedID: URL? |
| | | let focusToken: Int |
| | | let selectionScrollToken: Int |
| | |
| | | let onCommand: (QuickSearchCommand) -> Void |
| | | let onHover: (QuickSearchResult) -> Void |
| | | let onLaunch: (QuickSearchResult) -> Void |
| | | let onTagFilterChange: (String?) -> Void |
| | | |
| | | func makeNSView(context: Context) -> QuickSearchPanelAnchorView { |
| | | QuickSearchPanelAnchorView() |
| | |
| | | QuickSearchOverlayView( |
| | | query: $query, |
| | | results: results, |
| | | tagFilters: tagFilters, |
| | | selectedTagName: selectedTagName, |
| | | selectedID: selectedID, |
| | | focusToken: focusToken, |
| | | selectionScrollToken: selectionScrollToken, |
| | |
| | | errorMessage: errorMessage, |
| | | onCommand: onCommand, |
| | | onHover: onHover, |
| | | onLaunch: onLaunch |
| | | onLaunch: onLaunch, |
| | | onTagFilterChange: onTagFilterChange |
| | | ) |
| | | ) |
| | | ) |
| | |
| | | override var canBecomeMain: Bool { false } |
| | | |
| | | override func sendEvent(_ event: NSEvent) { |
| | | if event.type == .leftMouseDown, |
| | | let row = quickSearchResultRow(at: event.locationInWindow) { |
| | | if event.type == .leftMouseDown { |
| | | if let tagChip = quickSearchTagFilterClickTarget(at: event.locationInWindow), |
| | | tagChip.activateFromMouseClick() { |
| | | return |
| | | } |
| | | if let row = quickSearchResultRow(at: event.locationInWindow) { |
| | | row.launchFromMouseClick() |
| | | return |
| | | } |
| | | } |
| | | super.sendEvent(event) |
| | | } |
| | |
| | | } |
| | | return contentView.quickSearchResultRowDescendant(containingWindowPoint: windowPoint) |
| | | } |
| | | |
| | | private func quickSearchTagFilterClickTarget(at windowPoint: NSPoint) -> QuickSearchTagFilterClickTargetView? { |
| | | guard let contentView else { return nil } |
| | | if let chip = contentView.quickSearchTagFilterClickTargetFromHitTest(atWindowPoint: windowPoint) { |
| | | return chip |
| | | } |
| | | return contentView.quickSearchTagFilterClickTargetDescendant(containingWindowPoint: windowPoint) |
| | | } |
| | | } |
| | | |
| | | private extension NSView { |
| | | func quickSearchTagFilterClickTargetFromHitTest(atWindowPoint windowPoint: NSPoint) -> QuickSearchTagFilterClickTargetView? { |
| | | let localPoint = convert(windowPoint, from: nil) |
| | | var hitView = hitTest(localPoint) |
| | | while let candidate = hitView { |
| | | if let chip = candidate as? QuickSearchTagFilterClickTargetView { |
| | | return chip |
| | | } |
| | | hitView = candidate.superview |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | func quickSearchTagFilterClickTargetDescendant(containingWindowPoint windowPoint: NSPoint) -> QuickSearchTagFilterClickTargetView? { |
| | | for subview in subviews.reversed() { |
| | | if let chip = subview as? QuickSearchTagFilterClickTargetView, |
| | | chip.containsWindowPointForActivation(windowPoint) { |
| | | return chip |
| | | } |
| | | if let chip = subview.quickSearchTagFilterClickTargetDescendant(containingWindowPoint: windowPoint) { |
| | | return chip |
| | | } |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | func quickSearchResultRowFromHitTest(atWindowPoint windowPoint: NSPoint) -> QuickSearchResultRowView? { |
| | | let localPoint = convert(windowPoint, from: nil) |
| | | var hitView = hitTest(localPoint) |
| | |
| | | struct QuickSearchOverlayView: View { |
| | | @Binding var query: String |
| | | let results: [QuickSearchResult] |
| | | let tagFilters: [QuickSearchTagFilterOption] |
| | | let selectedTagName: String? |
| | | let selectedID: URL? |
| | | let focusToken: Int |
| | | let selectionScrollToken: Int |
| | |
| | | let onCommand: (QuickSearchCommand) -> Void |
| | | let onHover: (QuickSearchResult) -> Void |
| | | let onLaunch: (QuickSearchResult) -> Void |
| | | let onTagFilterChange: (String?) -> Void |
| | | |
| | | @Environment(\.colorScheme) private var colorScheme |
| | | |
| | |
| | | |
| | | private var panelShape: RoundedRectangle { |
| | | RoundedRectangle(cornerRadius: 34, style: .continuous) |
| | | } |
| | | |
| | | private var hasTagFilters: Bool { |
| | | !tagFilters.isEmpty |
| | | } |
| | | |
| | | private var emptyResultsSystemImage: String { |
| | | if selectedTagName != nil { |
| | | return "tag" |
| | | } |
| | | return query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "keyboard" : "magnifyingglass" |
| | | } |
| | | |
| | | private var emptyResultsMessage: String { |
| | | if let selectedTagName { |
| | | return tr("quickSearch.tagFilter.noResults", replacements: ["%tagName%": selectedTagName]) |
| | | } |
| | | return query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | ? tr("quickSearch.emptyPrompt") |
| | | : tr("quickSearch.noResults") |
| | | } |
| | | |
| | | var body: some View { |
| | |
| | | .padding(.top, 22) |
| | | .padding(.bottom, 18) |
| | | |
| | | if hasTagFilters { |
| | | QuickSearchTagFilterBar( |
| | | filters: tagFilters, |
| | | selectedTagName: selectedTagName, |
| | | onSelect: onTagFilterChange |
| | | ) |
| | | .frame(height: QuickSearchPanelMetrics.tagFilterBarHeight) |
| | | } |
| | | |
| | | Divider().opacity(0.35) |
| | | |
| | | if isLoading { |
| | |
| | | ) |
| | | } else if results.isEmpty { |
| | | QuickSearchMessageRow( |
| | | systemImage: query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "keyboard" : "magnifyingglass", |
| | | message: query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty |
| | | ? tr("quickSearch.emptyPrompt") |
| | | : tr("quickSearch.noResults"), |
| | | systemImage: emptyResultsSystemImage, |
| | | message: emptyResultsMessage, |
| | | tint: .secondary |
| | | ) |
| | | } else { |
| | |
| | | } |
| | | } |
| | | |
| | | private struct QuickSearchTagFilterBar: View { |
| | | let filters: [QuickSearchTagFilterOption] |
| | | let selectedTagName: String? |
| | | let onSelect: (String?) -> Void |
| | | |
| | | var body: some View { |
| | | ScrollView(.horizontal, showsIndicators: false) { |
| | | HStack(spacing: 8) { |
| | | QuickSearchTagFilterChip( |
| | | title: tr("quickSearch.tagFilter.all"), |
| | | colorIndex: nil, |
| | | customColor: nil, |
| | | isSelected: selectedTagName == nil, |
| | | accessibilityLabel: tr("quickSearch.tagFilter.allAccessibility") |
| | | ) { |
| | | onSelect(nil) |
| | | } |
| | | |
| | | ForEach(filters) { filter in |
| | | QuickSearchTagFilterChip( |
| | | title: filter.name, |
| | | colorIndex: filter.colorIndex, |
| | | customColor: filter.customColor, |
| | | isSelected: selectedTagName == filter.name, |
| | | accessibilityLabel: tr( |
| | | "quickSearch.tagFilter.tagAccessibility", |
| | | replacements: ["%tagName%": filter.name] |
| | | ) |
| | | ) { |
| | | onSelect(selectedTagName == filter.name ? nil : filter.name) |
| | | } |
| | | } |
| | | } |
| | | .padding(.horizontal, 28) |
| | | .padding(.vertical, 5) |
| | | } |
| | | .accessibilityElement(children: .contain) |
| | | } |
| | | } |
| | | |
| | | private struct QuickSearchTagFilterChip: View { |
| | | let title: String |
| | | let colorIndex: Int? |
| | | let customColor: TagCustomColor? |
| | | let isSelected: Bool |
| | | let accessibilityLabel: String |
| | | let action: () -> Void |
| | | |
| | | @Environment(\.colorScheme) private var colorScheme |
| | | @State private var isHovered = false |
| | | |
| | | private var tagDotColor: Color? { |
| | | guard let colorIndex else { return nil } |
| | | return Color(nsColor: TagColor.nsColor(for: colorIndex, customColor: customColor)) |
| | | } |
| | | |
| | | private var backgroundColor: Color { |
| | | if isSelected { |
| | | return Color.primary.opacity(colorScheme == .dark ? 0.18 : 0.10) |
| | | } |
| | | return Color.primary.opacity(isHovered ? (colorScheme == .dark ? 0.11 : 0.075) : (colorScheme == .dark ? 0.08 : 0.055)) |
| | | } |
| | | |
| | | private var strokeColor: Color { |
| | | Color.primary.opacity(isSelected ? 0.34 : 0.11) |
| | | } |
| | | |
| | | var body: some View { |
| | | Button(action: action) { |
| | | HStack(spacing: 7) { |
| | | if let tagDotColor { |
| | | Circle() |
| | | .fill(tagDotColor) |
| | | .frame(width: 7, height: 7) |
| | | } |
| | | |
| | | Text(title) |
| | | .font(.system(size: 13, weight: .semibold)) |
| | | .lineLimit(1) |
| | | .truncationMode(.tail) |
| | | .foregroundStyle(.primary) |
| | | .layoutPriority(1) |
| | | } |
| | | .padding(.horizontal, 11) |
| | | .frame(maxWidth: 160, minHeight: 30, maxHeight: 30, alignment: .center) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .fill(backgroundColor) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 8, style: .continuous) |
| | | .stroke(strokeColor, lineWidth: isSelected ? 1.2 : 1) |
| | | ) |
| | | .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) |
| | | } |
| | | .buttonStyle(.plain) |
| | | .help(title) |
| | | .onHover { isHovered = $0 } |
| | | .overlay( |
| | | QuickSearchTagFilterClickTarget(action: action) |
| | | .accessibilityHidden(true) |
| | | ) |
| | | .accessibilityLabel(accessibilityLabel) |
| | | .accessibilityAddTraits(isSelected ? .isSelected : []) |
| | | } |
| | | } |
| | | |
| | | private struct QuickSearchTagFilterClickTarget: NSViewRepresentable { |
| | | let action: () -> Void |
| | | |
| | | func makeNSView(context: Context) -> QuickSearchTagFilterClickTargetView { |
| | | let view = QuickSearchTagFilterClickTargetView() |
| | | view.onActivate = action |
| | | return view |
| | | } |
| | | |
| | | func updateNSView(_ view: QuickSearchTagFilterClickTargetView, context: Context) { |
| | | view.onActivate = action |
| | | } |
| | | } |
| | | |
| | | private final class QuickSearchTagFilterClickTargetView: NSView { |
| | | var onActivate: (() -> Void)? |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { |
| | | true |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | guard activateFromMouseClick() else { |
| | | super.mouseDown(with: event) |
| | | return |
| | | } |
| | | } |
| | | |
| | | @discardableResult |
| | | func activateFromMouseClick() -> Bool { |
| | | guard let onActivate else { return false } |
| | | DispatchQueue.main.async { [onActivate] in |
| | | onActivate() |
| | | } |
| | | return true |
| | | } |
| | | |
| | | func containsWindowPointForActivation(_ windowPoint: NSPoint) -> Bool { |
| | | guard !isHidden, window != nil else { return false } |
| | | let localPoint = convert(windowPoint, from: nil) |
| | | return bounds.contains(localPoint) && visibleRect.contains(localPoint) |
| | | } |
| | | } |
| | | |
| | | private struct QuickSearchResultListView: NSViewRepresentable { |
| | | let results: [QuickSearchResult] |
| | | let selectedID: URL? |