feat: add quick search tag filtering
36 files modified
1 files added
| | |
| | | @State private var quickSearchQuery = "" |
| | | @State private var quickSearchDocuments: [QuickSearchDocument] = [] |
| | | @State private var quickSearchResults: [QuickSearchResult] = [] |
| | | @State private var quickSearchSelectedTagName: String? = nil |
| | | @State private var quickSearchSelectedID: URL? = nil |
| | | @State private var quickSearchManualSelection = false |
| | | @State private var quickSearchFocusToken = 0 |
| | |
| | | } |
| | | .onChange(of: defaultGroupName) { _, _ in |
| | | rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames) |
| | | if quickSearchVisible { |
| | | refreshQuickSearchResults() |
| | | } |
| | | } |
| | | .onChange(of: quickSearchQuery) { _, _ in |
| | | quickSearchErrorMessage = nil |
| | |
| | | QuickSearchPanelPresentationView( |
| | | query: $quickSearchQuery, |
| | | results: quickSearchResults, |
| | | tagFilters: quickSearchTagFilters, |
| | | selectedTagName: quickSearchSelectedTagName, |
| | | selectedID: quickSearchSelectedID, |
| | | focusToken: quickSearchFocusToken, |
| | | selectionScrollToken: quickSearchSelectionScrollToken, |
| | |
| | | errorMessage: quickSearchErrorMessage, |
| | | onCommand: handleQuickSearchCommand, |
| | | onHover: selectQuickSearchResult, |
| | | onLaunch: launchQuickSearchResult |
| | | onLaunch: launchQuickSearchResult, |
| | | onTagFilterChange: selectQuickSearchTagFilter |
| | | ) |
| | | .frame(width: 0, height: 0) |
| | | .allowsHitTesting(false) |
| | |
| | | && !quickSearchResults.isEmpty |
| | | let visibleRows = min(max(1, quickSearchResults.count), quickSearchMaxVisibleRows(in: size)) |
| | | return QuickSearchPanelMetrics.contentHeight( |
| | | hasTagFilters: !quickSearchTagFilters.isEmpty, |
| | | hasResultList: hasResultList, |
| | | visibleRows: visibleRows |
| | | ) |
| | |
| | | private func quickSearchMaxVisibleRows(in size: CGSize) -> Int { |
| | | let bottomClearance: CGFloat = 84 |
| | | let chromeHeight = QuickSearchPanelMetrics.headerHeight |
| | | + (quickSearchTagFilters.isEmpty ? 0 : QuickSearchPanelMetrics.tagFilterBarHeight) |
| | | + QuickSearchPanelMetrics.dividerHeight |
| | | + QuickSearchPanelMetrics.resultListVerticalInset * 2 |
| | | let rowHeightWithSpacing = QuickSearchPanelMetrics.rowHeight |
| | |
| | | quickSearchVisible = true |
| | | quickSearchOpenedAt = Date() |
| | | quickSearchQuery = "" |
| | | quickSearchSelectedTagName = nil |
| | | quickSearchCompositionActive = false |
| | | quickSearchManualSelection = false |
| | | quickSearchErrorMessage = nil |
| | |
| | | quickSearchCompositionActive = false |
| | | quickSearchQuery = "" |
| | | quickSearchResults = [] |
| | | quickSearchSelectedTagName = nil |
| | | quickSearchSelectedID = nil |
| | | quickSearchManualSelection = false |
| | | quickSearchErrorMessage = nil |
| | |
| | | |
| | | private func refreshQuickSearchResults() { |
| | | let previousSelection = quickSearchSelectedID |
| | | let documents = quickSearchVisibleDocuments |
| | | let selectedTagName = validateQuickSearchSelectedTagName(against: quickSearchTagFilters) |
| | | quickSearchResults = QuickSearchEngine.search( |
| | | quickSearchQuery, |
| | | documents: quickSearchDocuments.filter { FileManager.default.fileExists(atPath: $0.app.path.path) } |
| | | documents: documents, |
| | | selectedTagName: selectedTagName |
| | | ) |
| | | |
| | | if quickSearchResults.isEmpty { |
| | |
| | | quickSearchSelectedID = quickSearchResults.first?.id |
| | | quickSearchManualSelection = false |
| | | } |
| | | } |
| | | |
| | | private var quickSearchVisibleDocuments: [QuickSearchDocument] { |
| | | quickSearchDocuments.filter { FileManager.default.fileExists(atPath: $0.app.path.path) } |
| | | } |
| | | |
| | | private var quickSearchTagFilters: [QuickSearchTagFilterOption] { |
| | | let availableTagNames = Set( |
| | | quickSearchVisibleDocuments |
| | | .flatMap(\.tagNames) |
| | | .filter(isQuickSearchFilterableTag) |
| | | ) |
| | | guard !availableTagNames.isEmpty else { return [] } |
| | | |
| | | let ordered = draggedTagNames |
| | | .filter { availableTagNames.contains($0) && isQuickSearchFilterableTag($0) } |
| | | let orderedSet = Set(ordered) |
| | | let remaining = availableTagNames |
| | | .filter { !orderedSet.contains($0) } |
| | | .sorted { $0.localizedStandardCompare($1) == .orderedAscending } |
| | | |
| | | return (ordered + remaining).map { tagName in |
| | | let definition = tagDefinitions[tagName] |
| | | return QuickSearchTagFilterOption( |
| | | name: tagName, |
| | | colorIndex: definition?.color ?? tagColors[tagName] ?? 0, |
| | | customColor: definition?.customColor ?? tagCustomColors[tagName] |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private func isQuickSearchFilterableTag(_ tagName: String) -> Bool { |
| | | let name = tagName.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | guard !name.isEmpty, tagDefinitions[name] != nil else { return false } |
| | | return !AppContainerID.isReservedGroupName(name, defaultGroupName: defaultGroupName) |
| | | } |
| | | |
| | | private func validateQuickSearchSelectedTagName(against filters: [QuickSearchTagFilterOption]) -> String? { |
| | | guard let quickSearchSelectedTagName else { return nil } |
| | | guard filters.contains(where: { $0.name == quickSearchSelectedTagName }) else { |
| | | self.quickSearchSelectedTagName = nil |
| | | return nil |
| | | } |
| | | return quickSearchSelectedTagName |
| | | } |
| | | |
| | | private func selectQuickSearchTagFilter(_ tagName: String?) { |
| | | quickSearchSelectedTagName = tagName |
| | | quickSearchErrorMessage = nil |
| | | quickSearchManualSelection = false |
| | | quickSearchFocusToken &+= 1 |
| | | refreshQuickSearchResults() |
| | | } |
| | | |
| | | private func handleQuickSearchCommand(_ command: QuickSearchCommand) { |
| | |
| | | quickSearchFocusToken &+= 1 |
| | | } |
| | | quickSearchOpenedAt = Date() |
| | | quickSearchSelectedTagName = nil |
| | | refreshQuickSearchResults() |
| | | refreshAppsForQuickSearch() |
| | | NotificationCenter.default.post( |
| | |
| | | return tag(name) |
| | | } |
| | | |
| | | static func isReservedGroupName(_ name: String, defaultGroupName: String = "Other") -> Bool { |
| | | reservedGroupNames(defaultGroupName: defaultGroupName).contains( |
| | | name.trimmingCharacters(in: .whitespacesAndNewlines) |
| | | ) |
| | | } |
| | | |
| | | private static func isUncategorizedDisplayName(_ name: String) -> Bool { |
| | | name == "Other" || name == tr("group.uncategorized") |
| | | } |
| | | |
| | | private static func isAppleBuiltInDisplayName(_ name: String) -> Bool { |
| | | name == "Mac自带" || name == tr("group.appleBuiltIn") |
| | | } |
| | | |
| | | private static func reservedGroupNames(defaultGroupName: String) -> Set<String> { |
| | | let names = [ |
| | | "Other", |
| | | defaultGroupName, |
| | | tr("group.uncategorized"), |
| | | "Mac自带", |
| | | tr("group.appleBuiltIn"), |
| | | TagDatabase.uncommonTagKey, |
| | | tr("group.uncommon") |
| | | ] |
| | | return Set(names.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }) |
| | | } |
| | | } |
| | | |
| | |
| | | } else { |
| | | for tag in uniqueOrdered(app.tags) { |
| | | let displayName = nameOverrides[tag] ?? tag |
| | | guard displayName != macCategory else { continue } |
| | | guard !AppContainerID.isReservedGroupName(displayName, defaultGroupName: defaultGroupName) else { |
| | | continue |
| | | } |
| | | appendGroupedApp( |
| | | app, |
| | | to: displayName, |
| | |
| | | tagOrder: tagOrder |
| | | ) { |
| | | let displayName = nameOverrides[tagName] ?? tagName |
| | | guard displayName != macCategory, |
| | | displayName != defaultGroupName |
| | | else { continue } |
| | | guard !AppContainerID.isReservedGroupName(displayName, defaultGroupName: defaultGroupName) else { |
| | | continue |
| | | } |
| | | |
| | | if containerIDsByGroupName[displayName] == nil { |
| | | containerIDsByGroupName[displayName] = AppContainerID.forTag( |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>8.3.7</string> |
| | | <string>8.4.0</string> |
| | | <key>CFBundleURLTypes</key> |
| | | <array> |
| | | <dict> |
| | |
| | | </dict> |
| | | </array> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260822.2207</string> |
| | | <string>20260828.1732</string> |
| | | <key>LSApplicationCategoryType</key> |
| | | <string>public.app-category.utilities</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | |
| | | "quickSearch.inputAccessibility": "حقل البحث السريع", |
| | | "quickSearch.emptyPrompt": "ابدأ بالكتابة عشان تبحث في التطبيقات والوسوم والملاحظات.", |
| | | "quickSearch.noResults": "ما لقينا تطبيقات.", |
| | | "quickSearch.tagFilter.all": "الكل", |
| | | "quickSearch.tagFilter.noResults": "ما لقينا تطبيقات في %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "إظهار كل نتائج البحث السريع", |
| | | "quickSearch.tagFilter.tagAccessibility": "تصفية البحث السريع حسب الوسم %tagName%", |
| | | "quickSearch.launchFailed": "تعذر فتح هالتطبيق. جرّب مرة ثانية أو اختر تطبيق ثاني.", |
| | | "quickSearch.tagPrefix": "وسم", |
| | | "quickSearch.hotkeys": "اختصارات لوحة المفاتيح", |
| | |
| | | "quickSearch.inputAccessibility": "حقل البحث السريع", |
| | | "quickSearch.emptyPrompt": "ابدأ بالكتابة للبحث في التطبيقات والوسوم والملاحظات.", |
| | | "quickSearch.noResults": "لم يتم العثور على تطبيقات.", |
| | | "quickSearch.tagFilter.all": "الكل", |
| | | "quickSearch.tagFilter.noResults": "لم يتم العثور على تطبيقات في %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "إظهار كل نتائج البحث السريع", |
| | | "quickSearch.tagFilter.tagAccessibility": "تصفية البحث السريع حسب الوسم %tagName%", |
| | | "quickSearch.launchFailed": "تعذر فتح هذا التطبيق. حاول مرة أخرى أو اختر تطبيقًا آخر.", |
| | | "quickSearch.tagPrefix": "وسم", |
| | | "quickSearch.hotkeys": "اختصارات لوحة المفاتيح", |
| | |
| | | "quickSearch.inputAccessibility": "Vstup rychlého hledání", |
| | | "quickSearch.emptyPrompt": "Začněte psát a hledejte aplikace, štítky a poznámky.", |
| | | "quickSearch.noResults": "Nebyly nalezeny žádné aplikace.", |
| | | "quickSearch.tagFilter.all": "Vše", |
| | | "quickSearch.tagFilter.noResults": "V %tagName% nebyly nalezeny žádné aplikace.", |
| | | "quickSearch.tagFilter.allAccessibility": "Zobrazit všechny výsledky rychlého hledání", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrovat rychlé hledání podle štítku %tagName%", |
| | | "quickSearch.launchFailed": "Tuto aplikaci nelze otevřít. Zkuste to znovu nebo vyberte jinou aplikaci.", |
| | | "quickSearch.tagPrefix": "Štítek", |
| | | "quickSearch.hotkeys": "Klávesové zkratky", |
| | |
| | | "quickSearch.inputAccessibility": "Input til hurtigsøgning", |
| | | "quickSearch.emptyPrompt": "Begynd at skrive for at søge efter apps, mærker og noter.", |
| | | "quickSearch.noResults": "Ingen apps fundet.", |
| | | "quickSearch.tagFilter.all": "Alle", |
| | | "quickSearch.tagFilter.noResults": "Ingen apps fundet i %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Vis alle resultater i Hurtig søgning", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrer Hurtig søgning efter tagget %tagName%", |
| | | "quickSearch.launchFailed": "Denne app kunne ikke åbnes. Prøv igen, eller vælg en anden app.", |
| | | "quickSearch.tagPrefix": "Mærke", |
| | | "quickSearch.hotkeys": "Tastaturgenveje", |
| | |
| | | "quickSearch.inputAccessibility": "Eingabefeld für Schnellsuche", |
| | | "quickSearch.emptyPrompt": "Tippen Sie, um Apps, Tags und Notizen zu suchen.", |
| | | "quickSearch.noResults": "Keine Apps gefunden.", |
| | | "quickSearch.tagFilter.all": "Alle", |
| | | "quickSearch.tagFilter.noResults": "Keine Apps in %tagName% gefunden.", |
| | | "quickSearch.tagFilter.allAccessibility": "Alle Quick Search-Ergebnisse anzeigen", |
| | | "quickSearch.tagFilter.tagAccessibility": "Quick Search nach Tag %tagName% filtern", |
| | | "quickSearch.launchFailed": "Diese App konnte nicht geöffnet werden. Versuchen Sie es erneut oder wählen Sie eine andere App.", |
| | | "quickSearch.tagPrefix": "Tag", |
| | | "quickSearch.hotkeys": "Tastaturkurzbefehle", |
| | |
| | | "quickSearch.inputAccessibility": "Quick Search input", |
| | | "quickSearch.emptyPrompt": "Start typing to search apps, tags, and notes.", |
| | | "quickSearch.noResults": "No apps found.", |
| | | "quickSearch.tagFilter.all": "All", |
| | | "quickSearch.tagFilter.noResults": "No apps found in %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Show all Quick Search results", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filter Quick Search by tag %tagName%", |
| | | "quickSearch.launchFailed": "Unable to open this app. Please try again or choose another app.", |
| | | "quickSearch.tagPrefix": "Tag", |
| | | "quickSearch.hotkeys": "Keyboard shortcuts", |
| | |
| | | "quickSearch.inputAccessibility": "Campo de búsqueda rápida", |
| | | "quickSearch.emptyPrompt": "Empieza a escribir para buscar apps, etiquetas y notas.", |
| | | "quickSearch.noResults": "No se encontraron apps.", |
| | | "quickSearch.tagFilter.all": "Todo", |
| | | "quickSearch.tagFilter.noResults": "No se encontraron apps en %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Mostrar todos los resultados de búsqueda rápida", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrar búsqueda rápida por la etiqueta %tagName%", |
| | | "quickSearch.launchFailed": "No se pudo abrir esta app. Inténtalo de nuevo o elige otra app.", |
| | | "quickSearch.tagPrefix": "Etiqueta", |
| | | "quickSearch.hotkeys": "Atajos de teclado", |
| | |
| | | "quickSearch.inputAccessibility": "Champ de recherche rapide", |
| | | "quickSearch.emptyPrompt": "Commencez à saisir pour rechercher des apps, des étiquettes et des notes.", |
| | | "quickSearch.noResults": "Aucune app trouvée.", |
| | | "quickSearch.tagFilter.all": "Tout", |
| | | "quickSearch.tagFilter.noResults": "Aucune app trouvée dans %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Afficher tous les résultats de Recherche rapide", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrer Recherche rapide par le tag %tagName%", |
| | | "quickSearch.launchFailed": "Impossible d’ouvrir cette app. Réessayez ou choisissez une autre app.", |
| | | "quickSearch.tagPrefix": "Étiquette", |
| | | "quickSearch.hotkeys": "Raccourcis clavier", |
| | |
| | | "quickSearch.inputAccessibility": "Input pencarian cepat", |
| | | "quickSearch.emptyPrompt": "Mulai mengetik untuk mencari app, tag, dan catatan.", |
| | | "quickSearch.noResults": "Tidak ada app ditemukan.", |
| | | "quickSearch.tagFilter.all": "Semua", |
| | | "quickSearch.tagFilter.noResults": "Tidak ada app yang ditemukan di %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Tampilkan semua hasil Pencarian Cepat", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filter Pencarian Cepat menurut tag %tagName%", |
| | | "quickSearch.launchFailed": "App ini tidak dapat dibuka. Coba lagi atau pilih app lain.", |
| | | "quickSearch.tagPrefix": "Tag", |
| | | "quickSearch.hotkeys": "Pintasan keyboard", |
| | |
| | | "quickSearch.inputAccessibility": "Campo di ricerca rapida", |
| | | "quickSearch.emptyPrompt": "Inizia a digitare per cercare app, tag e note.", |
| | | "quickSearch.noResults": "Nessuna app trovata.", |
| | | "quickSearch.tagFilter.all": "Tutte", |
| | | "quickSearch.tagFilter.noResults": "Nessuna app trovata in %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Mostra tutti i risultati di Ricerca rapida", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtra Ricerca rapida per tag %tagName%", |
| | | "quickSearch.launchFailed": "Impossibile aprire questa app. Riprova o scegli un’altra app.", |
| | | "quickSearch.tagPrefix": "Tag", |
| | | "quickSearch.hotkeys": "Scorciatoie da tastiera", |
| | |
| | | "quickSearch.inputAccessibility": "クイック検索入力欄", |
| | | "quickSearch.emptyPrompt": "入力を開始してアプリ、タグ、メモを検索します。", |
| | | "quickSearch.noResults": "アプリが見つかりません。", |
| | | "quickSearch.tagFilter.all": "すべて", |
| | | "quickSearch.tagFilter.noResults": "%tagName% にアプリが見つかりません。", |
| | | "quickSearch.tagFilter.allAccessibility": "クイック検索のすべての結果を表示", |
| | | "quickSearch.tagFilter.tagAccessibility": "%tagName% タグでクイック検索を絞り込む", |
| | | "quickSearch.launchFailed": "このアプリを開けません。もう一度試すか、別のアプリを選択してください。", |
| | | "quickSearch.tagPrefix": "タグ", |
| | | "quickSearch.hotkeys": "キーボードショートカット", |
| | |
| | | "quickSearch.inputAccessibility": "빠른 검색 입력란", |
| | | "quickSearch.emptyPrompt": "앱, 태그, 메모를 검색하려면 입력을 시작하세요.", |
| | | "quickSearch.noResults": "앱을 찾을 수 없습니다.", |
| | | "quickSearch.tagFilter.all": "전체", |
| | | "quickSearch.tagFilter.noResults": "%tagName%에서 앱을 찾을 수 없습니다.", |
| | | "quickSearch.tagFilter.allAccessibility": "모든 빠른 검색 결과 보기", |
| | | "quickSearch.tagFilter.tagAccessibility": "%tagName% 태그로 빠른 검색 필터링", |
| | | "quickSearch.launchFailed": "이 앱을 열 수 없습니다. 다시 시도하거나 다른 앱을 선택하세요.", |
| | | "quickSearch.tagPrefix": "태그", |
| | | "quickSearch.hotkeys": "키보드 단축키", |
| | |
| | | "quickSearch.inputAccessibility": "Input carian pantas", |
| | | "quickSearch.emptyPrompt": "Mula menaip untuk mencari app, tag dan nota.", |
| | | "quickSearch.noResults": "Tiada app ditemui.", |
| | | "quickSearch.tagFilter.all": "Semua", |
| | | "quickSearch.tagFilter.noResults": "Tiada app ditemui dalam %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Tunjukkan semua hasil Carian Pantas", |
| | | "quickSearch.tagFilter.tagAccessibility": "Tapis Carian Pantas mengikut tag %tagName%", |
| | | "quickSearch.launchFailed": "App ini tidak dapat dibuka. Cuba lagi atau pilih app lain.", |
| | | "quickSearch.tagPrefix": "Tag", |
| | | "quickSearch.hotkeys": "Pintasan papan kekunci", |
| | |
| | | "quickSearch.inputAccessibility": "Inndata for hurtigsøk", |
| | | "quickSearch.emptyPrompt": "Begynn å skrive for å søke etter apper, tagger og notater.", |
| | | "quickSearch.noResults": "Fant ingen apper.", |
| | | "quickSearch.tagFilter.all": "Alle", |
| | | "quickSearch.tagFilter.noResults": "Ingen apper funnet i %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Vis alle hurtigsøk-resultater", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrer hurtigsøk etter taggen %tagName%", |
| | | "quickSearch.launchFailed": "Kunne ikke åpne denne appen. Prøv igjen eller velg en annen app.", |
| | | "quickSearch.tagPrefix": "Tagg", |
| | | "quickSearch.hotkeys": "Tastatursnarveier", |
| | |
| | | "quickSearch.inputAccessibility": "Invoer voor snel zoeken", |
| | | "quickSearch.emptyPrompt": "Begin met typen om apps, tags en notities te zoeken.", |
| | | "quickSearch.noResults": "Geen apps gevonden.", |
| | | "quickSearch.tagFilter.all": "Alles", |
| | | "quickSearch.tagFilter.noResults": "Geen apps gevonden in %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Alle resultaten van Snel zoeken tonen", |
| | | "quickSearch.tagFilter.tagAccessibility": "Snel zoeken filteren op tag %tagName%", |
| | | "quickSearch.launchFailed": "Deze app kan niet worden geopend. Probeer opnieuw of kies een andere app.", |
| | | "quickSearch.tagPrefix": "Tag", |
| | | "quickSearch.hotkeys": "Toetscombinaties", |
| | |
| | | "quickSearch.inputAccessibility": "Inndata for hurtigsøk", |
| | | "quickSearch.emptyPrompt": "Begynn å skrive for å søke etter apper, tagger og notater.", |
| | | "quickSearch.noResults": "Fant ingen apper.", |
| | | "quickSearch.tagFilter.all": "Alle", |
| | | "quickSearch.tagFilter.noResults": "Fann ingen appar i %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Vis alle hurtigsøk-resultat", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrer hurtigsøk etter taggen %tagName%", |
| | | "quickSearch.launchFailed": "Kunne ikke åpne denne appen. Prøv igjen eller velg en annen app.", |
| | | "quickSearch.tagPrefix": "Tagg", |
| | | "quickSearch.hotkeys": "Tastatursnarvegar", |
| | |
| | | "quickSearch.inputAccessibility": "Inndata for hurtigsøk", |
| | | "quickSearch.emptyPrompt": "Begynn å skrive for å søke etter apper, tagger og notater.", |
| | | "quickSearch.noResults": "Fant ingen apper.", |
| | | "quickSearch.tagFilter.all": "Alle", |
| | | "quickSearch.tagFilter.noResults": "Ingen apper funnet i %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Vis alle hurtigsøk-resultater", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrer hurtigsøk etter taggen %tagName%", |
| | | "quickSearch.launchFailed": "Kunne ikke åpne denne appen. Prøv igjen eller velg en annen app.", |
| | | "quickSearch.tagPrefix": "Tagg", |
| | | "quickSearch.hotkeys": "Tastatursnarveier", |
| | |
| | | "quickSearch.inputAccessibility": "Pole szybkiego wyszukiwania", |
| | | "quickSearch.emptyPrompt": "Zacznij pisać, aby szukać aplikacji, tagów i notatek.", |
| | | "quickSearch.noResults": "Nie znaleziono aplikacji.", |
| | | "quickSearch.tagFilter.all": "Wszystkie", |
| | | "quickSearch.tagFilter.noResults": "Nie znaleziono aplikacji w %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Pokaż wszystkie wyniki szybkiego wyszukiwania", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtruj szybkie wyszukiwanie według tagu %tagName%", |
| | | "quickSearch.launchFailed": "Nie można otworzyć tej aplikacji. Spróbuj ponownie lub wybierz inną aplikację.", |
| | | "quickSearch.tagPrefix": "Tag", |
| | | "quickSearch.hotkeys": "Skróty klawiaturowe", |
| | |
| | | "quickSearch.inputAccessibility": "Campo de busca rápida", |
| | | "quickSearch.emptyPrompt": "Comece a digitar para buscar apps, etiquetas e notas.", |
| | | "quickSearch.noResults": "Nenhum app encontrado.", |
| | | "quickSearch.tagFilter.all": "Tudo", |
| | | "quickSearch.tagFilter.noResults": "Nenhum app encontrado em %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Mostrar todos os resultados da busca rápida", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrar busca rápida pela tag %tagName%", |
| | | "quickSearch.launchFailed": "Não foi possível abrir este app. Tente novamente ou escolha outro app.", |
| | | "quickSearch.tagPrefix": "Etiqueta", |
| | | "quickSearch.hotkeys": "Atalhos de teclado", |
| | |
| | | "quickSearch.inputAccessibility": "Câmp de căutare rapidă", |
| | | "quickSearch.emptyPrompt": "Începe să tastezi pentru a căuta aplicații, etichete și note.", |
| | | "quickSearch.noResults": "Nu s-au găsit aplicații.", |
| | | "quickSearch.tagFilter.all": "Toate", |
| | | "quickSearch.tagFilter.noResults": "Nu s-au găsit aplicații în %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Afișează toate rezultatele Căutării rapide", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrează Căutarea rapidă după eticheta %tagName%", |
| | | "quickSearch.launchFailed": "Această aplicație nu poate fi deschisă. Încearcă din nou sau alege altă aplicație.", |
| | | "quickSearch.tagPrefix": "Etichetă", |
| | | "quickSearch.hotkeys": "Scurtături de la tastatură", |
| | |
| | | "quickSearch.inputAccessibility": "Поле быстрого поиска", |
| | | "quickSearch.emptyPrompt": "Начните вводить, чтобы искать приложения, теги и заметки.", |
| | | "quickSearch.noResults": "Приложения не найдены.", |
| | | "quickSearch.tagFilter.all": "Все", |
| | | "quickSearch.tagFilter.noResults": "В %tagName% приложения не найдены.", |
| | | "quickSearch.tagFilter.allAccessibility": "Показать все результаты быстрого поиска", |
| | | "quickSearch.tagFilter.tagAccessibility": "Фильтровать быстрый поиск по тегу %tagName%", |
| | | "quickSearch.launchFailed": "Не удалось открыть это приложение. Повторите попытку или выберите другое приложение.", |
| | | "quickSearch.tagPrefix": "Тег", |
| | | "quickSearch.hotkeys": "Сочетания клавиш", |
| | |
| | | "quickSearch.inputAccessibility": "Поље брзе претраге", |
| | | "quickSearch.emptyPrompt": "Почните да куцате да претражите апликације, ознаке и белешке.", |
| | | "quickSearch.noResults": "Нема пронађених апликација.", |
| | | "quickSearch.tagFilter.all": "Све", |
| | | "quickSearch.tagFilter.noResults": "Нема апликација у %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Прикажи све резултате брзе претраге", |
| | | "quickSearch.tagFilter.tagAccessibility": "Филтрирај брзу претрагу по ознаци %tagName%", |
| | | "quickSearch.launchFailed": "Ову апликацију није могуће отворити. Покушајте поново или изаберите другу апликацију.", |
| | | "quickSearch.tagPrefix": "Ознака", |
| | | "quickSearch.hotkeys": "Пречице на тастатури", |
| | |
| | | "quickSearch.inputAccessibility": "Inmatning för snabbsökning", |
| | | "quickSearch.emptyPrompt": "Börja skriva för att söka appar, taggar och anteckningar.", |
| | | "quickSearch.noResults": "Inga appar hittades.", |
| | | "quickSearch.tagFilter.all": "Alla", |
| | | "quickSearch.tagFilter.noResults": "Inga appar hittades i %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Visa alla resultat i Snabbsökning", |
| | | "quickSearch.tagFilter.tagAccessibility": "Filtrera Snabbsökning efter taggen %tagName%", |
| | | "quickSearch.launchFailed": "Det gick inte att öppna appen. Försök igen eller välj en annan app.", |
| | | "quickSearch.tagPrefix": "Tagg", |
| | | "quickSearch.hotkeys": "Kortkommandon", |
| | |
| | | "quickSearch.inputAccessibility": "ช่องค้นหาอย่างรวดเร็ว", |
| | | "quickSearch.emptyPrompt": "เริ่มพิมพ์เพื่อค้นหาแอป แท็ก และบันทึก", |
| | | "quickSearch.noResults": "ไม่พบแอป", |
| | | "quickSearch.tagFilter.all": "ทั้งหมด", |
| | | "quickSearch.tagFilter.noResults": "ไม่พบแอปใน %tagName%", |
| | | "quickSearch.tagFilter.allAccessibility": "แสดงผลลัพธ์ Quick Search ทั้งหมด", |
| | | "quickSearch.tagFilter.tagAccessibility": "กรอง Quick Search ด้วยแท็ก %tagName%", |
| | | "quickSearch.launchFailed": "ไม่สามารถเปิดแอปนี้ได้ ลองอีกครั้งหรือเลือกแอปอื่น", |
| | | "quickSearch.tagPrefix": "แท็ก", |
| | | "quickSearch.hotkeys": "แป้นพิมพ์ลัด", |
| | |
| | | "quickSearch.inputAccessibility": "Hızlı arama girişi", |
| | | "quickSearch.emptyPrompt": "Uygulamaları, etiketleri ve notları aramak için yazmaya başlayın.", |
| | | "quickSearch.noResults": "Uygulama bulunamadı.", |
| | | "quickSearch.tagFilter.all": "Tümü", |
| | | "quickSearch.tagFilter.noResults": "%tagName% içinde uygulama bulunamadı.", |
| | | "quickSearch.tagFilter.allAccessibility": "Tüm Hızlı Arama sonuçlarını göster", |
| | | "quickSearch.tagFilter.tagAccessibility": "Hızlı Aramayı %tagName% etiketiyle filtrele", |
| | | "quickSearch.launchFailed": "Bu uygulama açılamadı. Yeniden deneyin veya başka bir uygulama seçin.", |
| | | "quickSearch.tagPrefix": "Etiket", |
| | | "quickSearch.hotkeys": "Klavye kısayolları", |
| | |
| | | "quickSearch.inputAccessibility": "Поле швидкого пошуку", |
| | | "quickSearch.emptyPrompt": "Почніть вводити, щоб шукати програми, теги й нотатки.", |
| | | "quickSearch.noResults": "Програми не знайдено.", |
| | | "quickSearch.tagFilter.all": "Усі", |
| | | "quickSearch.tagFilter.noResults": "У %tagName% не знайдено програм.", |
| | | "quickSearch.tagFilter.allAccessibility": "Показати всі результати швидкого пошуку", |
| | | "quickSearch.tagFilter.tagAccessibility": "Фільтрувати швидкий пошук за тегом %tagName%", |
| | | "quickSearch.launchFailed": "Не вдалося відкрити цю програму. Спробуйте ще раз або виберіть іншу програму.", |
| | | "quickSearch.tagPrefix": "Тег", |
| | | "quickSearch.hotkeys": "Комбінації клавіш", |
| | |
| | | "quickSearch.inputAccessibility": "Ô nhập tìm kiếm nhanh", |
| | | "quickSearch.emptyPrompt": "Bắt đầu nhập để tìm ứng dụng, thẻ và ghi chú.", |
| | | "quickSearch.noResults": "Không tìm thấy ứng dụng.", |
| | | "quickSearch.tagFilter.all": "Tất cả", |
| | | "quickSearch.tagFilter.noResults": "Không tìm thấy ứng dụng trong %tagName%.", |
| | | "quickSearch.tagFilter.allAccessibility": "Hiển thị tất cả kết quả Tìm kiếm nhanh", |
| | | "quickSearch.tagFilter.tagAccessibility": "Lọc Tìm kiếm nhanh theo thẻ %tagName%", |
| | | "quickSearch.launchFailed": "Không thể mở ứng dụng này. Hãy thử lại hoặc chọn ứng dụng khác.", |
| | | "quickSearch.tagPrefix": "Thẻ", |
| | | "quickSearch.hotkeys": "Phím tắt bàn phím", |
| | |
| | | "quickSearch.inputAccessibility": "快捷搜索输入框", |
| | | "quickSearch.emptyPrompt": "开始输入以搜索 App、标签和备注。", |
| | | "quickSearch.noResults": "未找到应用。", |
| | | "quickSearch.tagFilter.all": "全部", |
| | | "quickSearch.tagFilter.noResults": "“%tagName%”标签下未找到应用。", |
| | | "quickSearch.tagFilter.allAccessibility": "显示全部快捷搜索结果", |
| | | "quickSearch.tagFilter.tagAccessibility": "按“%tagName%”标签筛选快捷搜索", |
| | | "quickSearch.launchFailed": "无法打开这个应用。请重试或选择其他应用。", |
| | | "quickSearch.tagPrefix": "标签", |
| | | "quickSearch.hotkeys": "快捷键", |
| | |
| | | "quickSearch.inputAccessibility": "快速搜尋輸入框", |
| | | "quickSearch.emptyPrompt": "開始輸入以搜尋 App、標籤和備註。", |
| | | "quickSearch.noResults": "找不到 App。", |
| | | "quickSearch.tagFilter.all": "全部", |
| | | "quickSearch.tagFilter.noResults": "「%tagName%」標籤下找不到 App。", |
| | | "quickSearch.tagFilter.allAccessibility": "顯示全部快速搜尋結果", |
| | | "quickSearch.tagFilter.tagAccessibility": "依「%tagName%」標籤篩選快速搜尋", |
| | | "quickSearch.launchFailed": "無法開啟這個 App。請重試或選擇其他 App。", |
| | | "quickSearch.tagPrefix": "標籤", |
| | | "quickSearch.hotkeys": "快速鍵", |
| | |
| | | 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) { |
| | | 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) |
| | | } |
| | |
| | | } |
| | | 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? |
| | |
| | | ) { |
| | | let oldNames = self.items.map(\.name) |
| | | let newNames = items.map(\.name) |
| | | let needsRebuild = Set(oldNames) != Set(newNames) || self.orientation != orientation |
| | | let needsRebuild = oldNames.count != newNames.count || Set(oldNames) != Set(newNames) || self.orientation != orientation |
| | | self.items = items |
| | | self.orientation = orientation |
| | | self.contentInsets = contentInsets |
| | |
| | | } |
| | | |
| | | private func reuseButtonsInCurrentOrder() { |
| | | let existing = Dictionary(uniqueKeysWithValues: buttons.map { ($0.itemName, $0) }) |
| | | var existing: [String: [TagNavigationButton]] = [:] |
| | | for button in buttons { |
| | | existing[button.itemName, default: []].append(button) |
| | | } |
| | | buttons = items.compactMap { item in |
| | | guard let button = existing[item.name] else { return nil } |
| | | guard var candidates = existing[item.name], !candidates.isEmpty else { return nil } |
| | | let button = candidates.removeFirst() |
| | | existing[item.name] = candidates |
| | | button.configure(item: item, orientation: orientation) |
| | | button.refreshAppDropRegistration() |
| | | return button |
| | |
| | | |
| | | ## [Unreleased] |
| | | |
| | | ## [8.4.0] — 2026-08-28 |
| | | |
| | | - Quick Search 增加标签筛选条,可点击 `全部` 清除筛选,也可点击普通标签在该标签内继续搜索 App、标签和备注 |
| | | - 标签筛选保持运行时单选状态,不持久化,不改变 App Grid、Pro 权益、数据结构或发布链路 |
| | | - 修复 `全部` 标签在真实 UI 下不能稳定清除筛选的问题,并防止保护容器名触发 duplicate-key 崩溃 |
| | | - 补齐 29 个语种的 Quick Search 标签筛选文案,新增专项 QA 门禁与真实 UI 复测证据 |
| | | - 版本号更新为 `8.4.0`,Build 更新为 `20260828.1732` |
| | | |
| | | ## [8.3.7] — 2026-08-22 |
| | | |
| | | - 优化 App Grid 备注气泡输入框的默认提示文字颜色,在深色浮层上保持次级提示感但不再近似不可见 |
| New file |
| | |
| | | #!/usr/bin/env bash |
| | | set -euo pipefail |
| | | |
| | | ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" |
| | | QUICK_SEARCH="$ROOT_DIR/Apptag/QuickSearch.swift" |
| | | CONTENT_VIEW="$ROOT_DIR/Apptag/ContentView.swift" |
| | | DATA_LAYER="$ROOT_DIR/Apptag/DataLayer.swift" |
| | | TAG_NAVIGATION="$ROOT_DIR/Apptag/TagNavigationView.swift" |
| | | L10N_SWIFT="$ROOT_DIR/Apptag/L10n.swift" |
| | | LOCALIZATION_DIR="$ROOT_DIR/Apptag/Localization" |
| | | |
| | | python3 - "$QUICK_SEARCH" "$CONTENT_VIEW" "$DATA_LAYER" "$TAG_NAVIGATION" "$L10N_SWIFT" "$LOCALIZATION_DIR" <<'PY' |
| | | import json |
| | | import re |
| | | import sys |
| | | from pathlib import Path |
| | | |
| | | quick_search = Path(sys.argv[1]).read_text(encoding="utf-8") |
| | | content_view = Path(sys.argv[2]).read_text(encoding="utf-8") |
| | | data_layer = Path(sys.argv[3]).read_text(encoding="utf-8") |
| | | tag_navigation = Path(sys.argv[4]).read_text(encoding="utf-8") |
| | | l10n_swift = Path(sys.argv[5]).read_text(encoding="utf-8") |
| | | localization_dir = Path(sys.argv[6]) |
| | | |
| | | |
| | | def fail(message: str) -> None: |
| | | print(f"FAIL: {message}", file=sys.stderr) |
| | | sys.exit(1) |
| | | |
| | | |
| | | def require(condition: bool, message: str) -> None: |
| | | if not condition: |
| | | fail(message) |
| | | |
| | | |
| | | def require_contains(source: str, needle: str, message: str) -> None: |
| | | require(needle in source, message) |
| | | |
| | | |
| | | def block(source: str, pattern: str, message: str) -> str: |
| | | match = re.search(pattern, source, re.S) |
| | | require(match is not None, message) |
| | | return match.group(0) |
| | | |
| | | |
| | | search_block = block( |
| | | quick_search, |
| | | r"static func search\(\n _ query: String,.*?static func normalizeQuery", |
| | | "QuickSearchEngine.search block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("selectedTagName: String? = nil", "search must accept a runtime selected tag filter"), |
| | | ("let activeTagName = normalizedSelectedTagName(selectedTagName)", "search must normalize the selected tag name without persisting it"), |
| | | ("documents.filter { document in", "search must scope documents before text matching"), |
| | | ("document.tagNames.contains(tagName)", "search tag filter must use exact tag membership"), |
| | | ("if let activeTagName", "empty query must branch when a tag filter is active"), |
| | | ("tagFilteredEmptyQueryResults(", "empty query with selected tag must list that tag's apps"), |
| | | ("emptyQueryResults(documents: scopedDocuments, limit: min(limit, 6))", "empty query without selected tag must keep the existing recent/frequent cap"), |
| | | ("result(for: $0, tokens: tokens, selectedTagName: activeTagName)", "text query must combine keyword matching with the selected tag scope"), |
| | | ]: |
| | | require_contains(search_block, needle, message) |
| | | |
| | | require_contains( |
| | | quick_search, |
| | | "matchedTagName: matchedTagName ?? selectedTagName", |
| | | "filtered results should prefer the selected tag in the right-side row badge", |
| | | ) |
| | | require_contains( |
| | | quick_search, |
| | | "struct QuickSearchTagFilterOption: Identifiable, Equatable", |
| | | "Quick Search must expose tag filter options for the panel", |
| | | ) |
| | | |
| | | metrics_block = block( |
| | | quick_search, |
| | | r"enum QuickSearchPanelMetrics.*?struct QuickSearchPanelPresentationView", |
| | | "Quick Search panel metrics block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("static let tagFilterBarHeight: CGFloat = 40", "tag filter bar height must follow the V1 design contract"), |
| | | ("contentHeight(hasTagFilters: Bool", "panel height must account for the tag filter bar"), |
| | | ("let tagFilterHeight = hasTagFilters ? tagFilterBarHeight : 0", "panel height must only grow when real filters exist"), |
| | | ]: |
| | | require_contains(metrics_block, needle, message) |
| | | |
| | | presentation_block = block( |
| | | quick_search, |
| | | r"struct QuickSearchPanelPresentationView: NSViewRepresentable.*?private final class QuickSearchPanel", |
| | | "Quick Search presentation view block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("let tagFilters: [QuickSearchTagFilterOption]", "panel presentation must receive available tag filters"), |
| | | ("let selectedTagName: String?", "panel presentation must receive the selected tag"), |
| | | ("let onTagFilterChange: (String?) -> Void", "panel presentation must report tag changes"), |
| | | ("tagFilters: tagFilters", "panel presentation must pass filters into the overlay"), |
| | | ("selectedTagName: selectedTagName", "panel presentation must pass selected tag into the overlay"), |
| | | ("onTagFilterChange: onTagFilterChange", "panel presentation must pass tag change handler into the overlay"), |
| | | ]: |
| | | require_contains(presentation_block, needle, message) |
| | | |
| | | overlay_block = block( |
| | | quick_search, |
| | | r"struct QuickSearchOverlayView: View.*?private struct QuickSearchResultListView", |
| | | "Quick Search overlay block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("QuickSearchTagFilterBar(", "overlay must render the tag filter bar"), |
| | | ("ScrollView(.horizontal, showsIndicators: false)", "tag filter bar must be single-line horizontal scrolling"), |
| | | ("QuickSearchTagFilterChip(", "tag filter bar must render tappable chips"), |
| | | ("title: tr(\"quickSearch.tagFilter.all\")", "tag filter bar must include the localized All chip"), |
| | | ("onSelect(nil)", "tapping the All chip must clear the selected tag filter"), |
| | | ("onSelect(selectedTagName == filter.name ? nil : filter.name)", "tapping the selected tag must clear the filter"), |
| | | ("tr(\"quickSearch.tagFilter.noResults\"", "selected tag no-results copy must be localized"), |
| | | (".frame(maxWidth: 160, minHeight: 30, maxHeight: 30", "tag chips must cap long labels and keep a stable hit target"), |
| | | (".help(title)", "long or truncated tag chips must expose the full tag name"), |
| | | ]: |
| | | require_contains(overlay_block, needle, message) |
| | | |
| | | tag_bar_index = overlay_block.find("QuickSearchTagFilterBar(") |
| | | divider_index = overlay_block.find("Divider().opacity") |
| | | result_list_index = overlay_block.find("QuickSearchResultListView(") |
| | | require( |
| | | 0 <= tag_bar_index < divider_index < result_list_index, |
| | | "tag filter bar must sit between the search field and the result list divider", |
| | | ) |
| | | require( |
| | | "LazyVGrid" not in overlay_block and "GridItem" not in overlay_block, |
| | | "Quick Search results must remain long-list rows, not a square grid", |
| | | ) |
| | | require_contains( |
| | | overlay_block, |
| | | "QuickSearchResultListView(", |
| | | "Quick Search result list must continue using the existing long-row host", |
| | | ) |
| | | |
| | | panel_class = block( |
| | | quick_search, |
| | | r"private final class QuickSearchPanel: NSPanel.*?final class QuickSearchPanelAnchorView", |
| | | "QuickSearchPanel class block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("quickSearchTagFilterClickTarget(at: event.locationInWindow)", "panel mouse fallback must resolve tag filter chips from the event point"), |
| | | ("tagChip.activateFromMouseClick()", "panel mouse fallback must invoke the resolved tag chip action"), |
| | | ("quickSearchTagFilterClickTargetFromHitTest(atWindowPoint: windowPoint)", "panel tag fallback should keep the direct hitTest path"), |
| | | ("quickSearchTagFilterClickTargetDescendant(containingWindowPoint: windowPoint)", "panel tag fallback must keep a recursive geometry path"), |
| | | ("quickSearchResultRow(at: event.locationInWindow)", "panel mouse fallback must preserve result-row launch handling"), |
| | | ]: |
| | | require_contains(panel_class, needle, message) |
| | | tag_fallback_index = panel_class.find("quickSearchTagFilterClickTarget(at: event.locationInWindow)") |
| | | row_fallback_index = panel_class.find("quickSearchResultRow(at: event.locationInWindow)") |
| | | require( |
| | | 0 <= tag_fallback_index < row_fallback_index, |
| | | "panel mouse fallback must process tag chips before result rows", |
| | | ) |
| | | |
| | | chip_block = block( |
| | | quick_search, |
| | | r"private struct QuickSearchTagFilterChip: View.*?private struct QuickSearchResultListView", |
| | | "QuickSearchTagFilterChip block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("QuickSearchTagFilterClickTarget(action: action)", "tag chips must install a native mouse click target"), |
| | | ("private struct QuickSearchTagFilterClickTarget: NSViewRepresentable", "tag chips must use an AppKit-backed click target"), |
| | | ("private final class QuickSearchTagFilterClickTargetView: NSView", "tag chip native click target view is missing"), |
| | | ("override func acceptsFirstMouse(for event: NSEvent?) -> Bool", "tag chip native click target must accept first mouse"), |
| | | ("override func mouseDown(with event: NSEvent)", "tag chip native click target must handle real mouse down"), |
| | | ("func activateFromMouseClick() -> Bool", "tag chip native click target must expose a panel-level activation fallback"), |
| | | ("func containsWindowPointForActivation(_ windowPoint: NSPoint) -> Bool", "tag chip native click target must expose geometry containment"), |
| | | ("bounds.contains(localPoint) && visibleRect.contains(localPoint)", "tag chip geometry fallback must reject hidden or clipped chip areas"), |
| | | ]: |
| | | require_contains(chip_block, needle, message) |
| | | |
| | | state_block = block( |
| | | content_view, |
| | | r"// Quick Search\n.*?init\(hideOverlay", |
| | | "ContentView Quick Search state block is missing", |
| | | ) |
| | | require_contains( |
| | | state_block, |
| | | "@State private var quickSearchSelectedTagName: String? = nil", |
| | | "ContentView must keep selected tag as runtime state only", |
| | | ) |
| | | require( |
| | | "AppStorage(\"quickSearchSelectedTagName\")" not in content_view |
| | | and "UserDefaults.standard.set" not in block(content_view, r"private func selectQuickSearchTagFilter.*?private func handleQuickSearchCommand", "tag selection handler block is missing"), |
| | | "Quick Search tag filter must not be persisted", |
| | | ) |
| | | |
| | | content_panel_block = block( |
| | | content_view, |
| | | r"private var quickSearchOverlay: some View.*?private func openQuickSearch", |
| | | "ContentView Quick Search overlay block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("tagFilters: quickSearchTagFilters", "ContentView must pass real available tag filters into the panel"), |
| | | ("selectedTagName: quickSearchSelectedTagName", "ContentView must pass selected tag into the panel"), |
| | | ("onTagFilterChange: selectQuickSearchTagFilter", "ContentView must handle tag chip clicks"), |
| | | ("hasTagFilters: !quickSearchTagFilters.isEmpty", "panel height must account for visible filters"), |
| | | ("quickSearchTagFilters.isEmpty ? 0 : QuickSearchPanelMetrics.tagFilterBarHeight", "max visible rows must reserve height for the filter bar"), |
| | | ]: |
| | | require_contains(content_panel_block, needle, message) |
| | | |
| | | refresh_block = block( |
| | | content_view, |
| | | r"private func refreshQuickSearchResults\(\).*?private func handleQuickSearchCommand", |
| | | "ContentView Quick Search refresh block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("let documents = quickSearchVisibleDocuments", "refresh must filter out missing app paths once before searching"), |
| | | ("let selectedTagName = validateQuickSearchSelectedTagName(against: quickSearchTagFilters)", "refresh must clear deleted/renamed tag filters before searching"), |
| | | ("selectedTagName: selectedTagName", "refresh must pass the validated selected tag to search"), |
| | | ("private var quickSearchTagFilters: [QuickSearchTagFilterOption]", "ContentView must derive real tag filters"), |
| | | (".flatMap(\\.tagNames)", "available filters must come from indexed app tags"), |
| | | (".filter(isQuickSearchFilterableTag)", "available filters must exclude protected/system-only names"), |
| | | ("draggedTagNames", "available filters must follow user tag order"), |
| | | ("definition?.customColor", "available filters must preserve custom tag colors"), |
| | | ("guard !name.isEmpty, tagDefinitions[name] != nil else { return false }", "filter chips must only include real tag definitions"), |
| | | ("AppContainerID.isReservedGroupName(name, defaultGroupName: defaultGroupName)", "filter chips must use the shared reserved group-name guard"), |
| | | ("private func validateQuickSearchSelectedTagName(against filters: [QuickSearchTagFilterOption]) -> String?", "selected tag validation must return the active filter or clear it"), |
| | | ("quickSearchFocusToken &+= 1", "tag clicks must return focus to the search input"), |
| | | ]: |
| | | require_contains(refresh_block, needle, message) |
| | | |
| | | container_id_block = block( |
| | | data_layer, |
| | | r"enum AppContainerID.*?enum AppDisplayNameResolver", |
| | | "AppContainerID block is missing", |
| | | ) |
| | | for needle, message in [ |
| | | ("static func isReservedGroupName(_ name: String, defaultGroupName: String = \"Other\") -> Bool", "AppContainerID must expose a shared reserved group-name guard"), |
| | | ("\"Other\"", "reserved group-name guard must include the neutral uncategorized key"), |
| | | ("defaultGroupName", "reserved group-name guard must include the configured uncategorized key"), |
| | | ("tr(\"group.uncategorized\")", "reserved group-name guard must include localized uncategorized names"), |
| | | ("\"Mac自带\"", "reserved group-name guard must include the Apple built-in container"), |
| | | ("tr(\"group.appleBuiltIn\")", "reserved group-name guard must include localized Apple built-in names"), |
| | | ("TagDatabase.uncommonTagKey", "reserved group-name guard must include the system uncommon key"), |
| | | ("tr(\"group.uncommon\")", "reserved group-name guard must include localized uncommon names"), |
| | | ("trimmingCharacters(in: .whitespacesAndNewlines)", "reserved group-name guard must trim names before comparison"), |
| | | ]: |
| | | require_contains(container_id_block, needle, message) |
| | | |
| | | group_block = block( |
| | | data_layer, |
| | | r"static func group\(\n apps: \[AppInfo\],.*?static func orderedApps", |
| | | "AppIndexer.group block is missing", |
| | | ) |
| | | require_contains( |
| | | group_block, |
| | | "!AppContainerID.isReservedGroupName(displayName, defaultGroupName: defaultGroupName)", |
| | | "AppIndexer.group must skip protected container/system tag names before building display groups", |
| | | ) |
| | | require( |
| | | group_block.count("!AppContainerID.isReservedGroupName(displayName, defaultGroupName: defaultGroupName)") >= 2, |
| | | "AppIndexer.group must skip protected names in both app tag grouping and empty tag-definition groups", |
| | | ) |
| | | |
| | | reuse_block = block( |
| | | tag_navigation, |
| | | r"private func reuseButtonsInCurrentOrder\(\).*?private func updateButtonRuntimeState", |
| | | "TagNavigationView button reuse block is missing", |
| | | ) |
| | | require( |
| | | "Dictionary(uniqueKeysWithValues: buttons.map { ($0.itemName, $0) })" not in reuse_block, |
| | | "TagNavigationView must not fatal when duplicate display names reach button reuse", |
| | | ) |
| | | require_contains( |
| | | tag_navigation, |
| | | "oldNames.count != newNames.count", |
| | | "TagNavigationView must rebuild when duplicate-name counts change", |
| | | ) |
| | | for needle, message in [ |
| | | ("var existing: [String: [TagNavigationButton]] = [:]", "TagNavigationView must build a duplicate-tolerant reuse map"), |
| | | ("existing[button.itemName, default: []].append(button)", "TagNavigationView must keep every reusable button for duplicate names"), |
| | | ("let button = candidates.removeFirst()", "TagNavigationView must consume one reusable button per item"), |
| | | ]: |
| | | require_contains(reuse_block, needle, message) |
| | | |
| | | for pattern, message in [ |
| | | (r"private func openQuickSearch\(source: String\).*?private var canOpenQuickSearch", "openQuickSearch block is missing"), |
| | | (r"private func closeQuickSearch\(.*?private func shouldIgnoreEarlyQuickSearchDismiss", "closeQuickSearch block is missing"), |
| | | (r"private func handleContentAppear\(\).*?private func handleOverlayDidShow", "initial Quick Search block is missing"), |
| | | ]: |
| | | require_contains(block(content_view, pattern, message), "quickSearchSelectedTagName = nil", f"{message}; selected tag must reset on lifecycle transition") |
| | | |
| | | supported_match = re.search(r"static let supported: \[\(code: String, name: String\)\] = \[(.*?)\n \]", l10n_swift, re.S) |
| | | require(supported_match is not None, "L10n.supported block is missing") |
| | | supported_codes = re.findall(r'\("([^"]+)"\s*,', supported_match.group(1)) |
| | | required_l10n_keys = [ |
| | | "quickSearch.tagFilter.all", |
| | | "quickSearch.tagFilter.noResults", |
| | | "quickSearch.tagFilter.allAccessibility", |
| | | "quickSearch.tagFilter.tagAccessibility", |
| | | ] |
| | | for code in supported_codes: |
| | | file = localization_dir / f"{code}.json" |
| | | require(file.exists(), f"missing localization file for {code}") |
| | | try: |
| | | data = json.loads(file.read_text(encoding="utf-8")) |
| | | except json.JSONDecodeError as exc: |
| | | fail(f"invalid JSON in {file.name}: {exc}") |
| | | for key in required_l10n_keys: |
| | | value = data.get(key) |
| | | require(isinstance(value, str) and value.strip(), f"{file.name} missing non-empty {key}") |
| | | require("%tagName%" in data["quickSearch.tagFilter.noResults"], f"{file.name} no-results copy must include %tagName%") |
| | | require("%tagName%" in data["quickSearch.tagFilter.tagAccessibility"], f"{file.name} tag accessibility copy must include %tagName%") |
| | | |
| | | print("PASS quick search tag filter QA") |
| | | PY |
| | |
| | | - 编辑模式使用 runtime theme override:任何主题下进入编辑都临时显示默认浅色毛玻璃。 |
| | | - 使用最近一次完整 `AppLibrarySnapshot` 先渲染 AppGrid,再后台刷新,避免启动/重开时立刻显示转圈。 |
| | | - 使用技巧关闭提醒由 `ContentView` 统一弹 modal 并写入 `hideUsageTips` / `skipUsageTipsCloseReminder`。 |
| | | - Quick Search 标签筛选状态只存在于当前面板生命周期;`ContentView` 从当前索引和 `tagDefinitions` 生成可选真实标签,并通过 `AppContainerID.isReservedGroupName` 排除未分类 / Mac 自带 / 不常用等保护容器名,打开、关闭、初始 Quick Search、标签删除/重命名或刷新后失效时都清空筛选。 |
| | | - Quick Search 点击 TagLauncher 自身结果时只关闭当前浮层,不走外部自启动,避免 AppGrid 被重新拉起。 |
| | | - `C1.source/Apptag/PreferencesView.swift` |
| | | - `PreferencesView`:设置窗口、语言、通用、主题、快捷键、标签、数据、Pro、关于等设置页。 |
| | |
| | | - `C1.source/Apptag/DataLayer.swift` |
| | | - `AppInfo`:应用模型。 |
| | | - `AppDisplayNameResolver`:多语言应用显示名解析。 |
| | | - `AppIndexer`:扫描 `/Applications` 等应用来源,处理 bundle / wrapper / localized display name。 |
| | | - `AppIndexer`:扫描 `/Applications` 等应用来源,处理 bundle / wrapper / localized display name;主分组生成时通过 `AppContainerID.isReservedGroupName` 跳过被错误写成普通标签的未分类 / Mac 自带 / 不常用等保护容器名,避免本地化显示名重复导致标签导航 fatal。 |
| | | - `TagGroup` / `TagColor` / `TagCustomColor`:标签分组、基础色和可选 sRGB 自定义色;`TagDef.customColor` 为可选字段,旧 JSON 缺字段时保持兼容。 |
| | | - `TagDatabase`:标签、备注、隐藏状态、分类方案、导入导出、备份和持久化。 |
| | | - `TagEditor`:把数据库中的标签/备注/隐藏状态标注回扫描到的 App 列表;`setCustomColor` 是写入自定义标签颜色的唯一数据层入口,必须先过 Pro gate,基础色切换会清除自定义色。 |
| | |
| | | - `C1.source/Apptag/QuickSearch.swift` |
| | | - `LauncherHotkey` / `LauncherHotkeyKind`:主面板和 Quick Search 快捷键定义。 |
| | | - `LauncherHotkeyRegistrationStore`:快捷键注册状态持久化。 |
| | | - `QuickSearchDocument` / `QuickSearchEngine`:搜索索引与排序。 |
| | | - `QuickSearchDocument` / `QuickSearchEngine`:搜索索引与排序;`selectedTagName` 是运行时单选筛选条件,先按真实标签精确收窄文档,再与文本 token 做交集。无文本但已选标签时列出该标签下 App;无标签筛选的空搜索继续保持最近/常用候选。 |
| | | - `QuickSearchTagFilterOption` / `QuickSearchTagFilterBar` / `QuickSearchTagFilterChip`:搜索框下方、结果列表上方的单行横向标签筛选条;只在存在真实可用标签时显示,包含本地化 `全部` 和真实标签胶囊,不改结果长条列表形态。 |
| | | - `QuickSearchPanelPresentationView` / `QuickSearchOverlayView` / `QuickSearchResultListHostView`:Quick Search 浮层 UI 与结果列表;Quick Search 面板必须是可成为 key 的激活面板,不能使用 `.nonactivatingPanel`,否则 Return 可能落到前一个前台 App。 |
| | | - `QuickSearchPanel.sendEvent` 对左键点击做 AppKit 层兜底:先通过 `hitTest` 解析 `QuickSearchResultRowView`,失败时按窗口坐标递归扫描结果行几何命中,并走同一启动路径,避免 SwiftUI / NSHostingView / NSScrollView 层级吞掉候选行点击。 |
| | | - `QuickSearchPanel.sendEvent` 对左键点击做 AppKit 层兜底:先解析 `QuickSearchTagFilterClickTargetView` 并触发标签筛选 / 清除;再通过 `hitTest` 和窗口坐标递归几何命中解析 `QuickSearchResultRowView` 并走同一启动路径,避免 SwiftUI / NSHostingView / NSScrollView 层级吞掉标签 chip 或候选行点击。 |
| | | - `QuickSearchResultRowView`:结果行内部文字、图标、标签区域统一命中到整行,保证点击任意可见区域都能启动并关闭浮层。 |
| | | - `QuickSearchTextField` 同时保留 field editor command delegate 和 `NSControl.target/action` submit fallback,保证 Return / Enter 都能进入 `.submit`。 |
| | | - `C1.source/Apptag/LauncherHotkeySettings.swift` |
| | |
| | | - Quick Search 应用显示名回归。 |
| | | - `C1.source/Scripts/quick_search_system_app_qa.sh` |
| | | - Quick Search 系统应用回归。 |
| | | - `C1.source/Scripts/quick_search_tag_filter_qa.sh` |
| | | - Quick Search 标签筛选静态门禁;检查运行时单选标签筛选、`全部` 清除、tag chip 原生鼠标命中兜底、空搜索 + 标签列出该标签 App、标签 + 关键词交集、保护容器名从 Quick Search / 主分组排除且导航复用不因重复显示名 fatal、标签条横向胶囊、结果仍走长条候选项和 29 语种本地化 key 完整。 |
| | | - `C1.source/Scripts/quick_search_launch_contract_qa.sh` |
| | | - Quick Search 启动路径静态门禁;检查 in-app mouse monitor 不吞结果行点击、面板是激活 key panel、panel-level `sendEvent` 点击兜底存在、Return submit fallback 存在、点击 / submit 都接到 `NSWorkspace.openApplication` 启动路径。 |
| | | - `C1.source/Scripts/windowserver_event_safety_qa.sh` |
| | |
| | | - 旧用户 `useDarkAppGrid=true` 必须迁移到 `deepBlue` 主题。 |
| | | - AppGrid 启动或重开不应因为新 `ContentView` 初始 `allApps.isEmpty` 立刻显示转圈;应先复用最近一次完整快照,并延迟显示 loading。 |
| | | - Quick Search 显示标题应优先使用解析后的 `AppInfo.displayName`,内部 bundle 名只作为搜索字段。 |
| | | - Quick Search 标签筛选条只做 V1 单选运行时过滤:显示 `全部` + 当前索引中真实存在的标签;不得显示未分类 / Mac 自带 / 不常用等保护容器;点击已选标签或 `全部` 清除,并由原生鼠标命中兜底保持面板稳定打开;选中标签后输入关键词必须取交集;不得改成方块矩阵、不得持久化筛选状态。 |
| | | - Quick Search 结果行内部文字 / 图标区域不得吞掉点击;点中行内任意可见区域都应按结果启动路径处理。 |
| | | - Quick Search 打开后必须可靠接收键盘;面板不得是 non-activating panel,展示时必须激活 App 并成为 key window,Return / Enter 必须通过 command delegate 或 target/action fallback 进入 submit。 |
| | | - Quick Search 打开期间,本地 mouse monitor 不得吞掉 TagLauncher overlayWindow 内部事件;外部点击可关闭 Quick Search,结果行点击必须通过 `QuickSearchResultRowView.mouseDown` 或 `QuickSearchPanel.sendEvent` 兜底进入同一启动路径;panel 级兜底不得只依赖 `hitTest`,必须保留窗口坐标递归几何命中结果行的路径。 |
| | | - Quick Search 打开期间,本地 mouse monitor 不得吞掉 TagLauncher overlayWindow 内部事件;外部点击可关闭 Quick Search,标签 chip 和结果行点击必须通过对应 native view mouseDown 或 `QuickSearchPanel.sendEvent` 兜底进入同一筛选 / 启动路径;panel 级兜底不得只依赖 `hitTest`,必须保留窗口坐标递归几何命中路径。 |
| | | - Quick Search-only 点击 TagLauncher 自身结果必须关闭浮层,不得重新打开 AppGrid。 |
| | | - Apple 默认应用备注必须按当前语言显示,不允许 A 语言出现 B 语言备注。 |
| | | - SmartStart/default system tag 初始化标签必须按当前语言显示;升级用户启动后应重刷带 `systemCategoryID` 的系统标签,不能长期保留旧英文初始化标签。 |