From ec338c687e2e04bd37ab5c76a0a9c19769190502 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 30 Aug 2026 02:09:13 +0800
Subject: [PATCH] docs: record 8.4.0 testflight upload
---
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