diff --git a/Sources/DigUpApp/AppController.swift b/Sources/DigUpApp/AppController.swift index f061d628..e0aaea86 100644 --- a/Sources/DigUpApp/AppController.swift +++ b/Sources/DigUpApp/AppController.swift @@ -59,6 +59,10 @@ final class AppController { func start(showWindow: Bool = false) { panel = SearchPanelController(app: self) searchWindow = SearchWindowController(app: self) + AppLauncher.shared.onChange = { [weak self] in + self?.panel.appsChanged() + self?.searchWindow.search.appsChanged() + } settingsWindow = SettingsWindowController(app: self) statusMenu = StatusMenuController(model: model) statusMenu.onSearch = { [weak self] in self?.panel.show() } diff --git a/Sources/DigUpApp/Apps.swift b/Sources/DigUpApp/Apps.swift new file mode 100644 index 00000000..ca83d28a --- /dev/null +++ b/Sources/DigUpApp/Apps.swift @@ -0,0 +1,186 @@ +import AppKit +import CoreServices +import DigUpKit + +/// One app the panel can launch. +nonisolated struct InstalledApp: Sendable { + let url: URL + /// The name Finder shows ("Visual Studio Code"), then any other the app goes by (its bundle's: "Code"). + let names: [AppName] + let version: String? + /// When it was last opened, however it was (Spotlight keeps this for every app). + let lastUsed: Date? + let modified: Date + + var path: String { url.path } +} + +extension ResultRow { + /// The picture of the file, or the app's icon, without the arrow of a link (/Applications/Safari.app is one). + var iconURL: URL { isApp ? url.resolvingSymlinksInPath() : url } +} + +/// The apps in the Applications folders, found by name as you type (`AppSearch`): they come first in the panel, and ↩ +/// launches the top one, as in Spotlight or Alfred. The apps you launch from DigUp most, and the ones you've used +/// lately, come first among matches as good. +/// +/// The folders are read when the app starts and again, in the background, each time the panel opens (at most every 30 +/// seconds): an app you just installed shows up the next time you look. +final class AppLauncher { + static let shared = AppLauncher() + /// The most the panel shows: past a few, the name you typed fits too many apps to be much help. + static let limit = 4 + static let rescanAfter: TimeInterval = 30 + /// Off in Settings: the panel finds only files. + static let settingKey = "searchApps" + + /// Where apps are, and how many folders down to look (Utilities, an app's own folder like "Adobe Photoshop 2026", + /// Chrome's web apps). Never inside an app. + nonisolated static let folders: [(URL, depth: Int)] = [ + (URL(fileURLWithPath: "/Applications"), 2), + (FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications"), 2), + (URL(fileURLWithPath: "/System/Applications"), 1), + (URL(fileURLWithPath: "/System/Library/CoreServices/Applications"), 0), + ] + /// Apps that live outside those folders but people launch by name. + nonisolated static let extras = [URL(fileURLWithPath: "/System/Library/CoreServices/Finder.app")] + + private(set) var apps: [InstalledApp] = [] + private var scannedAt = Date.distantPast + private var scanning = false + /// Launches from the panel, by path (`appLaunches` in defaults). + private var launches: [String: Int] + /// The apps found changed (the first read, an app installed or removed). + var onChange: () -> Void = {} + + init() { + launches = AppSettings.defaults.dictionary(forKey: "appLaunches") as? [String: Int] ?? [:] + } + + var isOn: Bool { AppSettings.defaults.object(forKey: Self.settingKey) as? Bool ?? true } + + /// Reads the folders again in the background, unless that was done moments ago. + func refresh() { + guard isOn, !scanning, Date().timeIntervalSince(scannedAt) > Self.rescanAfter else { return } + scanning = true + let started = Date() + Task { + let found = await Task.detached(priority: .userInitiated) { Self.scan() }.value + let changed = found.map(\.path) != apps.map(\.path) + apps = found + scannedAt = Date() + scanning = false + if changed { + log("apps: \(found.count) in \(ms(since: started))") + onChange() + } + } + } + + /// The apps `query` names, best first, as panel rows. + func matches(_ query: String) -> [ResultRow] { + guard isOn else { return [] } + let now = Date() + return AppSearch.rank(query, apps, names: \.names, boost: { self.boost($0, now: now) }, limit: Self.limit) + .map(ResultRow.init(app:)) + } + + /// Opens the app (or brings it forward, if it's running), and remembers it was chosen. + func launch(_ url: URL) { + launches[url.path, default: 0] += 1 + AppSettings.defaults.set(launches, forKey: "appLaunches") + let name = url.deletingPathExtension().lastPathComponent + if Opener.dryRun { return log("launch (dry run): \(name)") } + log("launch: \(name)") + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + NSWorkspace.shared.openApplication(at: url, configuration: configuration) { _, error in + if let error { log("launch: \(name) failed: \(error.localizedDescription)") } + } + } + + /// How much to favor an app among matches as good: up to 200 for the ones launched from here most (25 launches), + /// and up to 60 for one used today, less for this week or this month. + private func boost(_ app: InstalledApp, now: Date) -> Int { + let launched = min(launches[app.path] ?? 0, 25) * 8 + let days = app.lastUsed.map { now.timeIntervalSince($0) / 86_400 } ?? .infinity + let recent = days < 1 ? 60 : days < 7 ? 40 : days < 30 ? 20 : 0 + return launched + recent + } + + // MARK: Reading the folders + + nonisolated static func scan() -> [InstalledApp] { + var found: [InstalledApp] = [] + var seen = Set() // an app in two places (by bundle ID, else path) shows once, from the first + func add(_ url: URL) { + guard let app = read(url) else { return } + let id = Bundle(url: url)?.bundleIdentifier ?? url.resolvingSymlinksInPath().path + guard seen.insert(id).inserted else { return } + found.append(app) + } + func look(in folder: URL, depth: Int) { + let keys: [URLResourceKey] = [.isDirectoryKey] + // Not `.skipsHiddenFiles`: /Applications/Safari.app is a link into a cryptex that's flagged hidden. + guard let items = try? FileManager.default.contentsOfDirectory( + at: folder, includingPropertiesForKeys: keys) else { return } + for item in items where !item.lastPathComponent.hasPrefix(".") { + if item.pathExtension == "app" { + add(item) + } else if depth > 0, (try? item.resourceValues(forKeys: Set(keys)))?.isDirectory == true { + look(in: item, depth: depth - 1) + } + } + } + for (folder, depth) in folders { look(in: folder, depth: depth) } + for url in extras { add(url) } + return found + } + + /// Nil for an app that can't be launched by hand (a background-only helper). + private nonisolated static func read(_ url: URL) -> InstalledApp? { + let bundle = Bundle(url: url) + let info = bundle?.infoDictionary ?? [:] + if info["LSBackgroundOnly"] as? Bool == true { return nil } + var shown = FileManager.default.displayName(atPath: url.path) + if shown.hasSuffix(".app") { shown = String(shown.dropLast(4)) } + var names = [AppName(shown)] + let others: [Any?] = [bundle?.localizedInfoDictionary?["CFBundleDisplayName"], info["CFBundleDisplayName"], + bundle?.localizedInfoDictionary?["CFBundleName"], info["CFBundleName"], + url.deletingPathExtension().lastPathComponent] + for other in others.compactMap({ $0 as? String }) where !other.isEmpty { + if !names.contains(where: { $0.name.lowercased() == other.lowercased() }) { names.append(AppName(other)) } + } + var lastUsed: Date? + if let item = MDItemCreateWithURL(kCFAllocatorDefault, url as CFURL) { + lastUsed = MDItemCopyAttribute(item, kMDItemLastUsedDate) as? Date + } + let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + return InstalledApp(url: url, names: names, version: info["CFBundleShortVersionString"] as? String, + lastUsed: lastUsed, modified: modified ?? .distantPast) + } +} + +extension ResultRow { + /// An app, as the panel lists it: by the name Finder shows, in the folder it's in. + init(app: InstalledApp) { + id = app.path + url = app.url + name = app.names.first?.name ?? app.url.deletingPathExtension().lastPathComponent + folder = tildePath(app.url.deletingLastPathComponent().path) + isApp = true + kind = .doc // unused for an app: nothing about one is a file kind's + segment = .name + segmentID = 0 + loc = nil + locEnd = nil + focus = nil + excerpt = nil + matchedMeaning = false + matchedWords = false + modified = app.modified + size = 0 + info = app.version.map { ["version": $0] } ?? [:] + terms = [] + } +} diff --git a/Sources/DigUpApp/Moments.swift b/Sources/DigUpApp/Moments.swift index 16a149f3..2731349d 100644 --- a/Sources/DigUpApp/Moments.swift +++ b/Sources/DigUpApp/Moments.swift @@ -79,8 +79,9 @@ enum QuickLookMoment { extension Engine { /// Opens `row` in its app: a PDF with words from its page to search for, a recording at its moment when the app - /// can be told it (see `Opener`). + /// can be told it (see `Opener`). An app's row launches the app. func open(_ row: ResultRow) { + if row.isApp { return AppLauncher.shared.launch(row.url) } if row.kind == .code { return CodeEditor.current.open(row.url, line: row.codeLine) } guard let page = row.page else { return Opener.open(row, searching: nil) } let (url, words) = (row.url, row.foundWords + row.terms) diff --git a/Sources/DigUpApp/Previews.swift b/Sources/DigUpApp/Previews.swift index 247abeeb..7bf5e572 100644 --- a/Sources/DigUpApp/Previews.swift +++ b/Sources/DigUpApp/Previews.swift @@ -200,6 +200,13 @@ struct PreviewPicture: View { Passage(row: row) .frame(maxWidth: size.width, maxHeight: size.height) .onTapGesture(count: 2, perform: open) + } else if row.isApp { + Image(nsImage: NSWorkspace.shared.icon(forFile: row.iconURL.path)) + .resizable() + .frame(width: 128, height: 128) + .contentShape(Rectangle()) + .onTapGesture(count: 2, perform: open) + .frame(maxWidth: size.width, maxHeight: size.height) } else if row.moment != nil, let player = PreviewPlayback.shared.player, PreviewPlayback.shared.isCurrent(row) { PlayerView(player: player) @@ -239,7 +246,7 @@ struct PreviewPicture: View { // without its controls. .onDisappear { if PreviewPlayback.shared.isCurrent(row) { PreviewPlayback.shared.stop() } } .task(id: Previews.shared.key(row, max(size.width, size.height))) { - guard row.segment != .chunk, row.kind != .code else { return } + guard row.segment != .chunk, row.kind != .code, !row.isApp else { return } let side = max(size.width, size.height) image = Previews.shared.cached(row, side: side) loaded = image != nil diff --git a/Sources/DigUpApp/Results.swift b/Sources/DigUpApp/Results.swift index 55c3c13d..69dec156 100644 --- a/Sources/DigUpApp/Results.swift +++ b/Sources/DigUpApp/Results.swift @@ -22,6 +22,8 @@ struct ResultRow: Identifiable, Equatable { let info: [String: String] /// The query's words (`Searcher.terms`): what the preview highlights on a page or in a passage. let terms: [String] + /// An app the panel can launch (`AppLauncher`), not a file from the index: its `kind` means nothing. + let isApp: Bool init(_ hit: SearchHit, terms: [String] = []) { id = hit.path @@ -47,6 +49,7 @@ struct ResultRow: Identifiable, Equatable { size = hit.size info = hit.info self.terms = terms + isApp = false } /// The name to show: left to right as Finder shows it, also when it starts in Arabic ("ملاحظة.md", not "md.ملاحظة"). @@ -115,6 +118,7 @@ struct ResultRow: Identifiable, Equatable { /// "Screenshot · Oct 1, 2026 · 1440 × 900 · 2.1 MB", "PDF · … · 463 pages, 120 read so far · 23 MB", /// "Python · 63 lines · Oct 1, 2026 · 2 KB" var details: String { + if isApp { return info["version"].map { "Application · version \($0)" } ?? "Application" } if kind == .code { var parts = [CodeExtractor.language(of: url.path)] if let cells = info["cells"].flatMap(Int.init) { @@ -140,6 +144,9 @@ struct ResultRow: Identifiable, Equatable { return parts.joined(separator: " · ") } + /// "Screenshot", "PDF", "Application": what it is, for VoiceOver. + var kindTitle: String { isApp ? "Application" : kind.title } + static func clock(_ seconds: Double) -> String { let total = Int(seconds) return total >= 3600 ? String(format: "%d:%02d:%02d", total / 3600, total / 60 % 60, total % 60) @@ -152,6 +159,8 @@ enum ResultGroupKind: CaseIterable { case screenshots, images, documents, video, audio /// Code search's results (the code index; a search of it never shows anything else). case code + /// Apps the query names (`AppLauncher`): first in the panel and the window's All results. + case apps /// The kinds of files the main index holds: the window's kind filters, ⌘2 to ⌘6. static let files: [ResultGroupKind] = [.screenshots, .images, .documents, .video, .audio] @@ -175,6 +184,7 @@ enum ResultGroupKind: CaseIterable { case .video: "Video" case .audio: "Audio" case .code: "Code" + case .apps: "Applications" } } @@ -186,6 +196,7 @@ enum ResultGroupKind: CaseIterable { case .video: FileKind.video.symbol case .audio: FileKind.audio.symbol case .code: FileKind.code.symbol + case .apps: "square.grid.2x2" } } @@ -197,6 +208,7 @@ enum ResultGroupKind: CaseIterable { case .video: [.video] case .audio: [.audio] case .code: [.code] + case .apps: [] } } } @@ -238,6 +250,16 @@ struct ResultSet: Equatable { weaker = hits.dropFirst(strong).map { ResultRow($0, terms: terms) } } + /// The same results with `apps` as the first group, in place of the apps they had. + func withApps(_ apps: [ResultRow]) -> ResultSet { + var set = self + set.groups.removeAll { $0.kind == .apps } + set.ranked.removeAll { $0.isApp } + set.ranked.insert(contentsOf: apps, at: 0) + if !apps.isEmpty { set.groups.insert(ResultGroup(kind: .apps, rows: apps, total: apps.count), at: 0) } + return set + } + var strongRows: [ResultRow] { groups.flatMap(\.rows) } var isEmpty: Bool { groups.isEmpty && weaker.isEmpty } var count: Int { strongRows.count + weaker.count } diff --git a/Sources/DigUpApp/SearchPanel.swift b/Sources/DigUpApp/SearchPanel.swift index ce158d3e..70a976a5 100644 --- a/Sources/DigUpApp/SearchPanel.swift +++ b/Sources/DigUpApp/SearchPanel.swift @@ -18,7 +18,8 @@ enum SearchPhase: Equatable { /// /// Every keystroke shows keyword results at once (file names, OCR and document text, the last word as a prefix). /// After a short pause in typing the query is embedded and the full hybrid results replace them. Results far weaker -/// than the best one are folded under "N weaker matches". +/// than the best one are folded under "N weaker matches". Apps the query names (`AppLauncher`) come first, at once, +/// so that ↩ launches one as in Spotlight. @Observable final class SearchPanelController { static let width: CGFloat = 780 @@ -54,6 +55,8 @@ final class SearchPanelController { /// The generation whose final (meaning) results are showing; late keyword results must not replace them. @ObservationIgnored private var finalGeneration = -1 @ObservationIgnored private var meaningTask: Task? + /// The apps the query names: the first group, whatever the index finds. + @ObservationIgnored private var apps: [ResultRow] = [] @ObservationIgnored private var keyMonitor: Any? @ObservationIgnored private var mouseMonitor: Any? @ObservationIgnored private var resignObserver: NSObjectProtocol? @@ -75,6 +78,7 @@ final class SearchPanelController { self.app = app model = app.model engine = app.engine + AppLauncher.shared.refresh() } var isVisible: Bool { window?.isVisible == true && !fading } @@ -99,6 +103,7 @@ final class SearchPanelController { let started = Date() shownAt = started engine.prewarm() + AppLauncher.shared.refresh() let window = self.window ?? makeWindow() placeWindow(window) if AppController.offscreen { return } @@ -169,6 +174,7 @@ final class SearchPanelController { isCode = codeText != nil let code = isCode let text = codeText ?? typed + apps = [] guard !typed.isEmpty else { results = .empty selection = nil @@ -184,6 +190,12 @@ final class SearchPanelController { resizeWindow() return } + // Apps by their names, now: ↩ launches the top one even before the index answers. + apps = code ? [] : AppLauncher.shared.matches(typed) + results = results.withApps(apps) + selection = rowIDs.first + if phase == .empty, !apps.isEmpty { phase = .words } + resizeWindow() // Words now. The last word is probably half-typed, so it matches as a prefix. Task { let started = Date() @@ -223,6 +235,7 @@ final class SearchPanelController { private func show(_ hits: [SearchHit], query: String, phase: SearchPhase) { // Code is one group: it gets the room of all of them. results = ResultSet(hits, query: query, rowsPerGroup: isCode ? Self.rowsPerGroup * 3 : Self.rowsPerGroup) + .withApps(apps) if !navigated { showWeaker = false } // Follow the top hit until you pick something with the arrow keys. if !navigated || !rowIDs.contains(where: { $0 == selection }) { @@ -233,6 +246,15 @@ final class SearchPanelController { if QLPreviewPanel.sharedPreviewPanelExists(), QLPreviewPanel.shared().isVisible { updateQuickLook() } } + /// The apps were read again and some came or went: the query's apps again. + func appsChanged() { + let typed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !typed.isEmpty, !isCode else { return } + apps = AppLauncher.shared.matches(typed) + results = results.withApps(apps) + if !navigated || !rowIDs.contains(where: { $0 == selection }) { selection = rowIDs.first } + } + func toggleWeaker() { showWeaker.toggle() selection = Self.weakerToggle diff --git a/Sources/DigUpApp/SearchPanelView.swift b/Sources/DigUpApp/SearchPanelView.swift index 4d1d0d54..d11bd25e 100644 --- a/Sources/DigUpApp/SearchPanelView.swift +++ b/Sources/DigUpApp/SearchPanelView.swift @@ -238,7 +238,8 @@ private struct PanelRow: View { var body: some View { HStack(spacing: 10) { // Code's thumbnail would be a page of tiny text: its file type's icon says more. - ThumbnailView(url: row.url, side: 36, iconOnly: row.kind == .code) + // So is an app's: its icon. + ThumbnailView(url: row.iconURL, side: 36, iconOnly: row.kind == .code || row.isApp) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(row.title) @@ -268,7 +269,7 @@ private struct PanelRow: View { .background(RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(selected ? Color.accentColor.opacity(0.24) : Color.clear)) .accessibilityElement(children: .combine) - .accessibilityLabel("\(row.kind.title), \(row.name), \(row.reason)") + .accessibilityLabel("\(row.kindTitle), \(row.name), \(row.reason)") .accessibilityAddTraits(selected ? .isSelected : []) } } diff --git a/Sources/DigUpApp/SearchWindow.swift b/Sources/DigUpApp/SearchWindow.swift index 4fc54cc6..c3593018 100644 --- a/Sources/DigUpApp/SearchWindow.swift +++ b/Sources/DigUpApp/SearchWindow.swift @@ -27,6 +27,8 @@ final class SearchWindowController: NSObject, NSWindowDelegate { search.filter = kind search.query = query } + AppLauncher.shared.refresh() + search.appsChanged() app.present(window) if !AppController.offscreen { window.makeFirstResponder(search.field) } installKeyMonitor() @@ -263,6 +265,16 @@ final class SearchModel { navigated = true } + /// Merge local apps without waiting for the file encoder or disturbing a selection made with the arrows. + func appsChanged() { + let typed = query.trimmingCharacters(in: .whitespacesAndNewlines) + let apps = !isCode && !typed.isEmpty ? AppLauncher.shared.matches(typed) : [] + counts[.apps] = apps.count + results = results.withApps(filter == nil ? apps : []) + if !results.isEmpty { hasResults = true } + if !navigated || !rows.contains(where: { $0.id == selection }) { selection = rows.first?.id } + } + private func run() { generation += 1 let current = generation @@ -285,6 +297,11 @@ final class SearchModel { codePhase = engine.hasCode ? .codeHint : .codeOff return } + results = .empty + counts = [:] + hasResults = false + showWeaker = false + appsChanged() engine.prewarm() engine.embedAhead(text, code: code) let filter = self.filter @@ -317,6 +334,7 @@ final class SearchModel { for hit in all.prefix(Searcher.strongCount(all)) { counts[ResultGroupKind(hit.kind), default: 0] += 1 } self.counts = counts results = ResultSet(shown, query: text) + appsChanged() showWeaker = false if !rows.contains(where: { $0.id == selection }) { selection = rows.first?.id } hasResults = true @@ -418,7 +436,7 @@ struct SearchWindowView: View { } @ViewBuilder private var content: some View { - if !model.hasFolders { + if !model.hasFolders && search.results.isEmpty { VStack(spacing: 0) { BrandMark() Text("Choose folders to search").font(.system(size: 20, weight: .bold)).padding(.top, 18) @@ -618,7 +636,7 @@ private struct GridCell: View { } .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay(alignment: .topLeading) { - if row.kind != .image && row.kind != .screenshot { + if !row.isApp && row.kind != .image && row.kind != .screenshot { Image(systemName: row.kind.symbol) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.white) @@ -648,7 +666,7 @@ private struct GridCell: View { } .contentShape(Rectangle()) .accessibilityElement(children: .ignore) - .accessibilityLabel("\(row.kind.title), \(row.name), \(row.reason)") + .accessibilityLabel("\(row.kindTitle), \(row.name), \(row.reason)") .accessibilityAddTraits(selected ? .isSelected : []) } } @@ -767,7 +785,10 @@ private struct MomentThumbnail: View { var body: some View { Group { - if let image { + if row.isApp { + Image(nsImage: NSWorkspace.shared.icon(forFile: row.iconURL.path)) + .resizable().scaledToFit().frame(width: 64, height: 64) + } else if let image { // A page shows its top (its title), anything else its middle. Image(nsImage: image).resizable().aspectRatio(contentMode: .fill) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: row.page == nil ? .center : .top) @@ -778,6 +799,7 @@ private struct MomentThumbnail: View { } } .task(id: row.previewKey) { + guard !row.isApp else { return } let side: CGFloat = 230 image = Previews.shared.cached(row, side: side) if image == nil { image = await Previews.shared.load(row, side: side, scale: scale) } diff --git a/Sources/DigUpApp/SettingsWindow.swift b/Sources/DigUpApp/SettingsWindow.swift index f56d40fd..21bf9d10 100644 --- a/Sources/DigUpApp/SettingsWindow.swift +++ b/Sources/DigUpApp/SettingsWindow.swift @@ -201,6 +201,7 @@ private struct SettingsPane: View { private struct GeneralSettings: View { @Bindable var state: SettingsState + @AppStorage(AppLauncher.settingKey, store: AppSettings.defaults) private var searchApps = true var body: some View { Form { @@ -217,6 +218,17 @@ private struct GeneralSettings: View { } } } + Section { + Toggle(isOn: $searchApps) { + VStack(alignment: .leading, spacing: 2) { + Text("Find apps by name") + Text("Apps whose names you type come first in search, and Return opens the top one.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } Section { Toggle(isOn: Binding(get: { state.launchAtLogin }, set: { on in state.loginProblem = state.app.setLaunchAtLogin(on) diff --git a/Sources/DigUpKit/AppSearch.swift b/Sources/DigUpKit/AppSearch.swift new file mode 100644 index 00000000..afd7f992 --- /dev/null +++ b/Sources/DigUpKit/AppSearch.swift @@ -0,0 +1,106 @@ +import Foundation + +/// An app's name, ready to match what's typed against it the way launchers do: the whole name from its start, the start +/// of a word in it ("code" for Visual Studio Code, "term" for iTerm), the starts of several of its words in order +/// ("vis stu"), or its initials ("vsc"). Never the middle of a word: "cat" must not launch Calculator when you're +/// looking for a cat. +public struct AppName: Sendable, Equatable { + public let name: String + let folded: String + let words: [String] + let initials: String + + public init(_ name: String) { + self.name = name + folded = Self.fold(name) + words = Self.words(of: name).map(Self.fold) + initials = String(words.compactMap(\.first)) + } + + /// How well `query` matches (already `AppSearch.normalized`): nil for not at all, higher for better. + func score(_ query: String) -> Int? { + guard !query.isEmpty else { return nil } + if folded == query { return 1000 } + if folded.hasPrefix(query) { return 800 } + let parts = query.split(separator: " ").map(String.init) + if Self.startsWords(parts, of: words) { return 600 } + if parts.count == 1, query.count >= 2, initials.hasPrefix(query) { return 500 } + return nil + } + + /// Each part starts one of `words`, in order: "vis stu" in Visual Studio Code, "code" in it too. + private static func startsWords(_ parts: [String], of words: [String]) -> Bool { + var next = 0 + for part in parts { + guard let found = words[next...].firstIndex(where: { $0.hasPrefix(part) }) else { return false } + next = found + 1 + } + return true + } + + static func fold(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil) + } + + /// "Visual Studio Code" → Visual, Studio, Code; "iTerm" → i, Term; "zoom.us" → zoom, us; "1Password 8" → 1, + /// Password, 8. Words end at anything that isn't a letter or a digit, and inside one where a capital follows a + /// small letter or letters and digits meet. + static func words(of name: String) -> [String] { + var words: [String] = [] + var word = "" + var previous: Character? + for character in name { + guard character.isLetter || character.isNumber else { + if !word.isEmpty { words.append(word) } + word = "" + previous = nil + continue + } + if let previous, !word.isEmpty, + (previous.isLowercase && character.isUppercase) || (previous.isNumber != character.isNumber) { + words.append(word) + word = "" + } + word.append(character) + previous = character + } + if !word.isEmpty { words.append(word) } + return words + } +} + +/// Finds apps by name as you type (`AppName`), best match first. Among matches as good, the ones favored (`boost`: +/// launched most, used lately) come first, then the shorter name ("Notes" before "Notes Helper"). +public enum AppSearch { + /// A name the app also goes by (the bundle's own, "Code" for Visual Studio Code) matches a little less well than + /// the one Finder shows. + static let otherNamePenalty = 50 + + /// The query as names are matched against it: folded like them, single spaces. + static func normalized(_ query: String) -> String { + AppName.fold(query).split(whereSeparator: \.isWhitespace).joined(separator: " ") + } + + /// The best of `apps` for `query`, at most `limit`. `names`: an app's names, the one Finder shows first. + public static func rank(_ query: String, _ apps: [App], names: (App) -> [AppName], + boost: (App) -> Int = { _ in 0 }, limit: Int) -> [App] { + let query = normalized(query) + guard !query.isEmpty, limit > 0 else { return [] } + var scored: [(app: App, score: Int, length: Int, name: String)] = [] + for app in apps { + let all = names(app) + guard let first = all.first else { continue } + let best = all.enumerated().compactMap { index, name in + name.score(query).map { $0 - (index == 0 ? 0 : otherNamePenalty) } + }.max() + guard let best else { continue } + scored.append((app, best + boost(app), first.name.count, first.folded)) + } + scored.sort { + if $0.score != $1.score { return $0.score > $1.score } + if $0.length != $1.length { return $0.length < $1.length } + return $0.name < $1.name + } + return scored.prefix(limit).map(\.app) + } +} diff --git a/Tests/DigUpKitTests/AppSearchTests.swift b/Tests/DigUpKitTests/AppSearchTests.swift new file mode 100644 index 00000000..97db08f4 --- /dev/null +++ b/Tests/DigUpKitTests/AppSearchTests.swift @@ -0,0 +1,74 @@ +import Testing +@testable import DigUpKit + +@Suite struct AppSearchTests { + static let apps = ["Safari", "Calculator", "Calendar", "Visual Studio Code", "iTerm", "Notes", "Notion", "Contacts", + "System Settings", "Café Racer", "1Password 8", "zoom.us", "FaceTime", "Screen Sharing"] + + func find(_ query: String, boost: [String: Int] = [:], also: [String: String] = [:], limit: Int = 4) -> [String] { + AppSearch.rank(query, Self.apps, names: { [AppName($0)] + (also[$0].map { [AppName($0)] } ?? []) }, + boost: { boost[$0] ?? 0 }, limit: limit) + } + + @Test func wordsOfNames() { + #expect(AppName.words(of: "Visual Studio Code") == ["Visual", "Studio", "Code"]) + #expect(AppName.words(of: "iTerm") == ["i", "Term"]) + #expect(AppName.words(of: "zoom.us") == ["zoom", "us"]) + #expect(AppName.words(of: "1Password 8") == ["1", "Password", "8"]) + #expect(AppName.words(of: "OBS") == ["OBS"]) + } + + @Test func startsOfNamesAndWords() { + #expect(find("saf") == ["Safari"]) + #expect(find("code") == ["Visual Studio Code"]) + #expect(find("term") == ["iTerm"]) + #expect(find("vis stu") == ["Visual Studio Code"]) + #expect(find("visual studio") == ["Visual Studio Code"]) + #expect(find("pass") == ["1Password 8"]) + #expect(find("time") == ["FaceTime"]) + #expect(find("SYSTEM set") == ["System Settings"]) + } + + @Test func initials() { + #expect(find("vsc") == ["Visual Studio Code"]) + #expect(find("ss") == ["Screen Sharing", "System Settings"]) + #expect(find("v") == ["Visual Studio Code"]) // one letter is the start of a word, never initials + } + + @Test func neverTheMiddleOfAWord() { + #expect(find("cat").isEmpty) // not Calculator, not Contacts + #expect(find("ari").isEmpty) // not Safari + #expect(find("a dog on the beach").isEmpty) + } + + @Test func accentsAndCaseDontMatter() { + #expect(find("cafe") == ["Café Racer"]) + #expect(find("CALC") == ["Calculator"]) + } + + @Test func betterMatchesFirstThenShorterNames() { + #expect(find("notes") == ["Notes"]) + #expect(find("no") == ["Notes", "Notion"]) // as good a match: the shorter name first + #expect(find("cal") == ["Calendar", "Calculator"]) + #expect(find("c", limit: 2) == ["Calendar", "Contacts"]) + } + + @Test func favoredAppsComeFirst() { + #expect(find("cal", boost: ["Calculator": 60]) == ["Calculator", "Calendar"]) + #expect(find("no", boost: ["Notion": 1]) == ["Notion", "Notes"]) + // The start of a later word, favored enough, beats the start of a name. + #expect(find("co", boost: ["Visual Studio Code": 260]) == ["Visual Studio Code", "Contacts"]) + } + + @Test func otherNamesMatchALittleLessWell() { + let also = ["Visual Studio Code": "Code"] + #expect(find("code", also: also) == ["Visual Studio Code"]) + #expect(find("co", also: also) == ["Contacts", "Visual Studio Code"]) + } + + @Test func nothingTypedFindsNothing() { + #expect(find("").isEmpty) + #expect(find(" ").isEmpty) + #expect(find("saf", limit: 0).isEmpty) + } +}