Ariver
2026-08-28 c5bdc7f1eb3ff4287beeeebb17d4f02f376a8d38
C1.source/Apptag/QuickSearch.swift
@@ -298,6 +298,13 @@
    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
@@ -329,14 +336,34 @@
            }
    }
    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 }
    }
@@ -350,7 +377,11 @@
            .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
@@ -379,7 +410,7 @@
            finalScore: finalScore,
            textScore: textScore,
            bestFieldRank: bestFieldRank,
            matchedTagName: matchedTagName,
            matchedTagName: matchedTagName ?? selectedTagName,
            noteSnippet: noteSnippet
        )
    }
@@ -549,6 +580,27 @@
        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
@@ -698,6 +750,12 @@
        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 = [
@@ -738,23 +796,28 @@
    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
@@ -766,6 +829,7 @@
    let onCommand: (QuickSearchCommand) -> Void
    let onHover: (QuickSearchResult) -> Void
    let onLaunch: (QuickSearchResult) -> Void
    let onTagFilterChange: (String?) -> Void
    func makeNSView(context: Context) -> QuickSearchPanelAnchorView {
        QuickSearchPanelAnchorView()
@@ -784,6 +848,8 @@
                QuickSearchOverlayView(
                    query: $query,
                    results: results,
                    tagFilters: tagFilters,
                    selectedTagName: selectedTagName,
                    selectedID: selectedID,
                    focusToken: focusToken,
                    selectionScrollToken: selectionScrollToken,
@@ -792,7 +858,8 @@
                    errorMessage: errorMessage,
                    onCommand: onCommand,
                    onHover: onHover,
                    onLaunch: onLaunch
                    onLaunch: onLaunch,
                    onTagFilterChange: onTagFilterChange
                )
            )
        )
@@ -957,10 +1024,15 @@
    override var canBecomeMain: Bool { false }
    override func sendEvent(_ event: NSEvent) {
        if event.type == .leftMouseDown,
           let row = quickSearchResultRow(at: event.locationInWindow) {
            row.launchFromMouseClick()
            return
        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)
    }
@@ -976,9 +1048,42 @@
        }
        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)
@@ -1051,6 +1156,8 @@
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
@@ -1060,6 +1167,7 @@
    let onCommand: (QuickSearchCommand) -> Void
    let onHover: (QuickSearchResult) -> Void
    let onLaunch: (QuickSearchResult) -> Void
    let onTagFilterChange: (String?) -> Void
    @Environment(\.colorScheme) private var colorScheme
@@ -1074,6 +1182,26 @@
    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 {
@@ -1096,6 +1224,15 @@
            .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 {
@@ -1112,10 +1249,8 @@
                )
            } 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 {
@@ -1152,6 +1287,159 @@
    }
}
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?