From c5bdc7f1eb3ff4287beeeebb17d4f02f376a8d38 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 28 Aug 2026 17:34:19 +0800
Subject: [PATCH] feat: add quick search tag filtering

---
 C1.source/Apptag/QuickSearch.swift |  320 ++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 304 insertions(+), 16 deletions(-)

diff --git a/C1.source/Apptag/QuickSearch.swift b/C1.source/Apptag/QuickSearch.swift
index f4365c4..1c0c33e 100644
--- a/C1.source/Apptag/QuickSearch.swift
+++ b/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?

--
Gitblit v1.9.3