From 23dc0d32a019ec75f6219c2886ee18f0595fa6a2 Mon Sep 17 00:00:00 2001 From: Oddur Sigurdsson Date: Fri, 16 Oct 2026 22:42:14 -0400 Subject: [PATCH] feat(panel): add scopes and actions like Spotlight on macOS 26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spotlight on macOS 26 narrows search with ⌘1–⌘4 and runs actions; without them Common Command felt a generation behind. Apps, Files, and Actions scopes each list what they hold when nothing is typed, which also makes the panel a Launchpad and a recent-files list. Actions run the user's Shortcuts through the shortcuts tool and quit or hide running apps, all with public API and no permissions. --- CHANGELOG.md | 4 + README.md | 10 +- Sources/CommandKit/Catalogs.swift | 6 + Sources/CommandKit/Item.swift | 7 +- Sources/CommandKit/Results.swift | 53 ++++++++- Sources/CommandKit/Scope.swift | 101 ++++++++++++++++ Sources/CommonCommand/AppController.swift | 37 ++++++ Sources/CommonCommand/Library.swift | 66 +++++++++-- Sources/CommonCommand/PanelController.swift | 47 +++++--- Sources/CommonCommand/PanelView.swift | 64 +++++++--- Sources/CommonCommand/Preferences.swift | 4 +- Sources/CommonCommand/SearchModel.swift | 38 ++++-- Sources/CommonCommand/SettingsView.swift | 11 +- Sources/CommonCommand/Snapshot.swift | 13 +- Tests/CommandKitTests/ScopeTests.swift | 124 ++++++++++++++++++++ 15 files changed, 529 insertions(+), 56 deletions(-) create mode 100644 Sources/CommandKit/Scope.swift create mode 100644 Tests/CommandKitTests/ScopeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c23a88..da9afd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,13 @@ All notable changes to this project are documented here. The format follows [Kee - Moving the pointer over a result selects it, and apps, files, and folders can be dragged out of the panel. - A macOS 26 look: a glass capsule for the field and a glass card for results, grouped into Top Hit, Applications, System Settings, Files, and Search the Web. A short fade on opening, none with Reduce Motion. - "No Results" when nothing matches and web search is off. +- Scopes, as in Spotlight on macOS 26: ⌘1 Apps, ⌘2 Files, ⌘3 Actions, also as buttons in the field. With nothing typed, a scope lists every app and pane, recent files, or all actions. +- Actions: run shortcuts from the Shortcuts app, and quit or hide running apps. ### Changed +- ⌘1–⌘9 choose a scope instead of opening the nth result, to match Spotlight. + - Rows say less: no "Application" label, a pane's name instead of "System Settings ›", and file paths keep their start and end when shortened. - System Settings topics match only where each typed word starts a word of the topic, and keywords only from the fourth letter, so a short query finds apps instead of a dozen loosely related settings. diff --git a/README.md b/README.md index af0db46..a9bd40c 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ Spotlight owns ⌘Space. To give it to Common Command, turn off **Show Spotlight | ↑ ↓, ⌃P ⌃N, Tab | Move the selection | | ↩ | Open the selected result, or copy a calculation | | ⌘↩ | Show the selected file or app in Finder | -| ⌘1 … ⌘9 | Open a result directly; hold ⌘ to see the numbers | -| ⎋ | Close | +| ⌘1 ⌘2 ⌘3 | Search only Apps, Files, or Actions; press again for everything | +| ⌫ in an empty field | Leave the scope | +| ⎋ | Leave the scope, or close | The pointer works too: click a result to open it, move over one to select it, or drag an app, file, or folder out into the Dock, Finder, or a document. Rows that shift under a still pointer while you type never take the selection. @@ -52,6 +53,11 @@ What a query finds, in order: 3. **Apps, System Settings, and Common Command's own commands** (Settings, Quit). Settings covers each pane and the topics inside it, found by the same keywords System Settings' own search uses: "dark mode" finds Appearance, "low power" finds Low Power Mode, "firewall" finds the firewall switch. Panes show their own icons. Laptop-only settings appear only on laptops. 4. **Files and folders** in your home folder whose name has a word starting with each word you typed. Build output, caches, and dot-folders are left out, and files you've opened recently rank higher. 5. **A web search** with your chosen engine: Google, DuckDuckGo, Bing, Kagi, Ecosia, or Startpage. +6. **Actions**: your shortcuts from the Shortcuts app, and Quit or Hide for each running app ("quit saf"). They rank below apps, so a name you type still means the app. + +### Scopes + +As in Spotlight on macOS 26, ⌘1, ⌘2, and ⌘3 (or the buttons at the end of the field) narrow the search to Apps, Files, or Actions. With nothing typed, a scope lists what it holds: every app and System Settings pane from A to Z, the files you opened in the last month, or your shortcuts and running apps. A scope searches its kind even if Settings leaves it out of everything else. Each of these except apps can be turned off in Settings. The last query comes back selected when you reopen the panel, so typing replaces it and Return repeats it. diff --git a/Sources/CommandKit/Catalogs.swift b/Sources/CommandKit/Catalogs.swift index 7c53e1b..c85f263 100644 --- a/Sources/CommandKit/Catalogs.swift +++ b/Sources/CommandKit/Catalogs.swift @@ -200,6 +200,12 @@ public enum FileResults { /// anyone is looking for. static let clutter = ["/Library/", "/.", ".app/", "/node_modules/", "/target/", "/DerivedData/", "/__pycache__/"] + /// Recently opened files, most recent first, for the Files scope with nothing typed. + public static func recent(_ found: [Found], home: String, now: Date) -> [Candidate] { + let ordered = found.filter { $0.lastUsed != nil }.sorted { $0.lastUsed! > $1.lastUsed! } + return candidates(ordered, home: home, now: now) + } + public static func candidates(_ found: [Found], home: String, now: Date) -> [Candidate] { // Spotlight can list one file twice, for instance while it re-indexes it. var seen: Set = [] diff --git a/Sources/CommandKit/Item.swift b/Sources/CommandKit/Item.swift index 1a78cb6..b3a0477 100644 --- a/Sources/CommandKit/Item.swift +++ b/Sources/CommandKit/Item.swift @@ -1,7 +1,7 @@ import Foundation public enum Kind: String, Sendable, Hashable { - case application, settings, command, file, folder, calculation, link, webSearch + case application, settings, command, action, file, folder, calculation, link, webSearch } /// What choosing a result does. @@ -11,6 +11,11 @@ public enum Action: Sendable, Hashable { case copy(String) /// Handled by the app shell: one of its own commands, such as opening Settings. case command(String) + /// Runs one of the user's shortcuts from the Shortcuts app, by name. + case shortcut(String) + /// Quits or hides a running app, by bundle identifier. + case quit(String) + case hide(String) } /// One row in the results. diff --git a/Sources/CommandKit/Results.swift b/Sources/CommandKit/Results.swift index 3e0c945..e5aedbe 100644 --- a/Sources/CommandKit/Results.swift +++ b/Sources/CommandKit/Results.swift @@ -8,6 +8,9 @@ public struct SearchOptions: Sendable, Equatable { public var limit = 8 public var fileLimit = 5 public var locale = Locale.current + public var scope = Scope.all + /// How many results a scope shows: more than everything does, since it's one kind of thing. + public var scopedLimit = 40 public init() {} } @@ -34,7 +37,19 @@ public enum Results { options: SearchOptions ) -> [ResultSection] { let text = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return [] } + let scope = options.scope + let candidates = candidates.filter { scope.includes($0.item.kind) } + let files = scope.includes(.file) ? files : [] + guard !text.isEmpty else { + return scope == .all ? [] : browse(scope, candidates: candidates, files: files) + } + guard scope == .all else { + let limit = options.scopedLimit + return group( + merge( + Ranker.rank(candidates, query: text, usage: usage, now: now, limit: limit), + Ranker.rank(files, query: text, usage: usage, now: now, limit: limit))) + } var sections: [ResultSection] = [] if let url = Web.address(text) { @@ -71,6 +86,41 @@ public enum Results { return sections } + /// A scope with nothing typed lists what it holds, as Spotlight's Apps view does: apps and panes from A + /// to Z, files in the order given (most recently used first), actions from A to Z. + static func browse(_ scope: Scope, candidates: [Candidate], files: [Candidate]) -> [ResultSection] { + func sorted(_ kind: Kind) -> [Ranked] { + // Settings topics are left out: hundreds of them would bury the panes. + candidates.filter { $0.item.kind == kind && !($0.item.kind == .settings && $0.matchesWordStarts) } + .sorted { $0.item.title.localizedStandardCompare($1.item.title) == .orderedAscending } + .map { Ranked(item: $0.item, score: 0) } + } + let sections: [ResultSection] + switch scope { + case .all: + sections = [] + case .apps: + sections = [ + ResultSection(title: "Applications", rows: sorted(.application)), + ResultSection(title: "System Settings", rows: sorted(.settings)), + ] + case .files: + sections = [ResultSection(title: "Recent Files", rows: files.map { Ranked(item: $0.item, score: 0) })] + case .actions: + // Shortcuts from A to Z; running apps in the order given, which keeps each app's Quit and Hide together. + let shortcuts = sorted(.action).filter { if case .shortcut = $0.item.action { true } else { false } } + let running = candidates.filter { + if case .shortcut = $0.item.action { return false } + return $0.item.kind == .action + } + sections = [ + ResultSection(title: "Shortcuts", rows: shortcuts), + ResultSection(title: "Running Apps", rows: running.map { Ranked(item: $0.item, score: 0) }), + ] + } + return sections.filter { !$0.rows.isEmpty } + } + /// Two best-first lists as one, best first; on a tie the first list's entry leads. static func merge(_ a: [Ranked], _ b: [Ranked]) -> [Ranked] { var merged: [Ranked] = [] @@ -108,6 +158,7 @@ public enum Results { case .application: "Applications" case .settings: "System Settings" case .command: "Common Command" + case .action: "Actions" case .file, .folder: "Files" case .calculation: "Calculator" case .link: "Website" diff --git a/Sources/CommandKit/Scope.swift b/Sources/CommandKit/Scope.swift new file mode 100644 index 0000000..74f913c --- /dev/null +++ b/Sources/CommandKit/Scope.swift @@ -0,0 +1,101 @@ +import Foundation + +/// What the search covers, as macOS 26 Spotlight splits it: everything, or only apps, files, or actions. +public enum Scope: String, CaseIterable, Sendable, Identifiable { + case all, apps, files, actions + + public var id: String { rawValue } + + public var name: String { + switch self { + case .all: "Everything" + case .apps: "Apps" + case .files: "Files" + case .actions: "Actions" + } + } + + /// The SF Symbol on the scope's button in the field. + public var symbol: String { + switch self { + case .all: "magnifyingglass" + case .apps: "square.grid.2x2" + case .files: "folder" + case .actions: "bolt" + } + } + + /// ⌘1, ⌘2, ⌘3, as in Spotlight. + public var key: Character? { + switch self { + case .all: nil + case .apps: "1" + case .files: "2" + case .actions: "3" + } + } + + public static let scoped: [Scope] = [.apps, .files, .actions] + + func includes(_ kind: Kind) -> Bool { + switch self { + case .all: true + case .apps: kind == .application || kind == .settings || kind == .command + case .files: kind == .file || kind == .folder + case .actions: kind == .action + } + } +} + +/// Things to do rather than open: the user's shortcuts from the Shortcuts app, and quitting or hiding +/// running apps. They rank below apps with an equally good name, so "saf" still means Safari. +public enum Actions { + static let weight = -6 + + /// The names `shortcuts list` prints, one per line. + public static func shortcutNames(fromList output: String) -> [String] { + output.split(whereSeparator: \.isNewline).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + } + + public static func shortcuts(_ names: [String], icon: URL?) -> [Candidate] { + var seen: Set = [] + return names.filter { seen.insert($0).inserted }.map { name in + Candidate( + Item( + id: "shortcut:" + name, title: name, subtitle: "Shortcut", kind: .action, + action: .shortcut(name), iconFile: icon), + weight: weight) + } + } + + public struct RunningApp: Sendable { + public var name: String + public var bundleIdentifier: String + public var url: URL + + public init(name: String, bundleIdentifier: String, url: URL) { + self.name = name + self.bundleIdentifier = bundleIdentifier + self.url = url + } + } + + /// "Quit Safari" and "Hide Safari" for each running app with a Dock icon, by app name. They match by + /// word starts only, so "quit sa" means Quit Safari and never the "sa" inside Quit Messages. + public static func appActions(_ apps: [RunningApp]) -> [Candidate] { + apps.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }.flatMap { app in + [ + Candidate( + Item( + id: "quit:" + app.bundleIdentifier, title: "Quit \(app.name)", subtitle: "Running", + kind: .action, action: .quit(app.bundleIdentifier), iconFile: app.url), + weight: weight, matchesWordStarts: true), + Candidate( + Item( + id: "hide:" + app.bundleIdentifier, title: "Hide \(app.name)", subtitle: "Running", + kind: .action, action: .hide(app.bundleIdentifier), iconFile: app.url), + weight: weight, matchesWordStarts: true), + ] + } + } +} diff --git a/Sources/CommonCommand/AppController.swift b/Sources/CommonCommand/AppController.swift index f8d03b7..154a9fb 100644 --- a/Sources/CommonCommand/AppController.swift +++ b/Sources/CommonCommand/AppController.swift @@ -143,6 +143,43 @@ final class AppController: NSObject { NSApp.terminate(nil) case .command: break + case .shortcut(let name): + runShortcut(name) + case .quit(let id): + NSRunningApplication.runningApplications(withBundleIdentifier: id).forEach { $0.terminate() } + case .hide(let id): + NSRunningApplication.runningApplications(withBundleIdentifier: id).forEach { $0.hide() } + } + } + + /// Runs a shortcut through the `shortcuts` tool, off the main thread: some take a while, and the panel + /// is already closed. A failure is reported, since nothing else would show it. + private func runShortcut(_ name: String) { + Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/shortcuts") + process.arguments = ["run", name] + let errors = Pipe() + process.standardError = errors + do { + try process.run() + process.waitUntilExit() + } catch { + await MainActor.run { Self.present(error) } + return + } + guard process.terminationStatus != 0 else { return } + let message = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + await MainActor.run { + Self.present( + CocoaError( + .featureUnsupported, + userInfo: [ + NSLocalizedDescriptionKey: "The shortcut “\(name)” didn't finish.", + NSLocalizedRecoverySuggestionErrorKey: message.trimmingCharacters( + in: .whitespacesAndNewlines), + ])) + } } } diff --git a/Sources/CommonCommand/Library.swift b/Sources/CommonCommand/Library.swift index 5823e6b..a2e64aa 100644 --- a/Sources/CommonCommand/Library.swift +++ b/Sources/CommonCommand/Library.swift @@ -1,3 +1,4 @@ +import AppKit import CommandKit import CoreServices import Foundation @@ -10,6 +11,8 @@ final class Library { private(set) var apps: [Candidate] = [] private(set) var panes: [Candidate] = [] var commands: [Candidate] = [] + private(set) var shortcuts: [Candidate] = [] + private(set) var appActions: [Candidate] = [] private(set) var usage: Usage var onChange: (() -> Void)? @@ -19,7 +22,7 @@ final class Library { /// installed app shows up the next time you look for it. private static let staleAfter: TimeInterval = 30 - nonisolated private static let log = Logger(subsystem: "com.oddurs.commoncommand", category: "usage") + nonisolated private static let log = Logger(subsystem: "com.oddurs.commoncommand", category: "library") private static var usageURL: URL { URL.applicationSupportDirectory.appending(path: "Common Command/usage.json") @@ -31,8 +34,20 @@ final class Library { ?? Usage() } - func candidates(includingPanes: Bool) -> [Candidate] { - includingPanes ? apps + panes + commands : apps + commands + func candidates(includingPanes: Bool, includingActions: Bool) -> [Candidate] { + apps + (includingPanes ? panes : []) + commands + (includingActions ? shortcuts + appActions : []) + } + + /// Quit and Hide for the apps running now. Read when the panel opens; cheap, and always current. + func refreshRunningApps() { + let own = Bundle.main.bundleIdentifier + appActions = Actions.appActions( + NSWorkspace.shared.runningApplications.compactMap { app in + guard app.activationPolicy == .regular, let id = app.bundleIdentifier, id != own, + let url = app.bundleURL, let name = app.localizedName + else { return nil } + return Actions.RunningApp(name: name, bundleIdentifier: id, url: url) + }) } /// Rescans in the background; the current lists keep answering until the new ones arrive. @@ -47,12 +62,37 @@ final class Library { self.apps = apps // System Settings changes only with macOS, so its panes are read once per launch. if needsPanes { self.panes = panes } + shortcuts = await Task.detached(priority: .userInitiated) { Self.listShortcuts() }.value scannedAt = Date() scanning = false onChange?() } } + nonisolated static let shortcutsApp = URL(filePath: "/System/Applications/Shortcuts.app") + + /// The user's shortcuts, from the `shortcuts` tool that ships with macOS. Takes about 50 ms, so it runs + /// with the app scan. If the tool fails, the list stays empty and the rest of search is unaffected. + nonisolated static func listShortcuts() -> [Candidate] { + let process = Process() + process.executableURL = URL(filePath: "/usr/bin/shortcuts") + process.arguments = ["list"] + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + log.error("couldn't list shortcuts: \(error.localizedDescription, privacy: .public)") + return [] + } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return [] } + return Actions.shortcuts( + Actions.shortcutNames(fromList: String(decoding: data, as: UTF8.self)), icon: shortcutsApp) + } + /// Whether this Mac runs on a battery, so laptop-only settings such as Low Power Mode are offered only /// where they exist. nonisolated static var hasBattery: Bool { @@ -88,13 +128,24 @@ enum FileSearch { static let maximumResults = 60 static func run(_ query: String) -> [Candidate] { - guard let string = FileQuery.spotlightQuery(for: query), - let mdQuery = MDQueryCreate(kCFAllocatorDefault, string as CFString, nil, nil) - else { return [] } + guard let string = FileQuery.spotlightQuery(for: query) else { return [] } + return FileResults.candidates( + found(matching: string), home: FileManager.default.homeDirectoryForCurrentUser.path, now: Date()) + } + + /// Files opened in the last month, most recent first, for the Files scope with nothing typed. + static func recent() -> [Candidate] { + let query = "kMDItemLastUsedDate >= $time.today(-30) && kMDItemContentType != \"com.apple.application-bundle\"" + return FileResults.recent( + found(matching: query), home: FileManager.default.homeDirectoryForCurrentUser.path, now: Date()) + } + + private static func found(matching string: String) -> [FileResults.Found] { + guard let mdQuery = MDQueryCreate(kCFAllocatorDefault, string as CFString, nil, nil) else { return [] } MDQuerySetSearchScope(mdQuery, ["kMDQueryScopeHome"] as CFArray, 0) MDQuerySetMaxCount(mdQuery, maximumResults) guard MDQueryExecute(mdQuery, CFOptionFlags(kMDQuerySynchronous.rawValue)) else { return [] } - let found = (0.. FileResults.Found? in + return (0.. FileResults.Found? in guard let raw = MDQueryGetResultAtIndex(mdQuery, index) else { return nil } let mdItem = Unmanaged.fromOpaque(raw).takeUnretainedValue() guard let path = MDItemCopyAttribute(mdItem, "kMDItemPath" as CFString) as? String else { return nil } @@ -103,6 +154,5 @@ enum FileSearch { isFolder: MDItemCopyAttribute(mdItem, "kMDItemContentType" as CFString) as? String == "public.folder", lastUsed: MDItemCopyAttribute(mdItem, "kMDItemLastUsedDate" as CFString) as? Date) } - return FileResults.candidates(found, home: FileManager.default.homeDirectoryForCurrentUser.path, now: Date()) } } diff --git a/Sources/CommonCommand/PanelController.swift b/Sources/CommonCommand/PanelController.swift index 032a348..2d43e10 100644 --- a/Sources/CommonCommand/PanelController.swift +++ b/Sources/CommonCommand/PanelController.swift @@ -6,7 +6,6 @@ import SwiftUI /// frontmost, its menu bar stays put, and closing the panel returns you to it with nothing to restore. final class CommandPanel: NSPanel { var onKeyEquivalent: ((NSEvent) -> Bool)? - var onModifiersChange: ((NSEvent.ModifierFlags) -> Void)? var onPointerMove: (() -> Void)? init() { @@ -35,12 +34,8 @@ final class CommandPanel: NSPanel { } override func sendEvent(_ event: NSEvent) { - switch event.type { - case .flagsChanged: onModifiersChange?(event.modifierFlags) // Before the event reaches the rows' hover tracking, so they know the pointer really moved. - case .mouseMoved: onPointerMove?() - default: break - } + if event.type == .mouseMoved { onPointerMove?() } super.sendEvent(event) } } @@ -68,9 +63,6 @@ final class PanelController: NSObject, NSWindowDelegate { super.init() panel.delegate = self panel.onKeyEquivalent = { [weak self] in self?.keyEquivalent($0) ?? false } - panel.onModifiersChange = { [weak self] flags in - self?.model.showsShortcuts = flags.intersection(.deviceIndependentFlagsMask) == .command - } panel.onPointerMove = { [weak self] in self?.model.pointerMoved = true } model.onResultsChange = { [weak self] in self?.resize() } library.onChange = { [weak self] in @@ -89,7 +81,8 @@ final class PanelController: NSObject, NSWindowDelegate { library.refreshIfStale() buildContentIfNeeded() panel.appearance = preferences.theme.appearance - model.showsShortcuts = false + library.refreshRunningApps() + model.scope = .all model.pointerMoved = false model.update() position() @@ -143,13 +136,32 @@ final class PanelController: NSObject, NSWindowDelegate { choose(model.selection, reveal: true) return true } - if let digit = Int(key), (1...9).contains(digit) { - choose(digit - 1) + // ⌘1, ⌘2, ⌘3 choose Apps, Files, or Actions, and pressing it again goes back to everything. + if let scope = Scope.scoped.first(where: { $0.key == key.first }) { + toggle(scope) return true } return false } + /// What `show()` reads fresh, without showing anything. + func prepareForSnapshot() { + library.refreshRunningApps() + } + + func toggle(_ scope: Scope) { + model.scope = model.scope == scope ? .all : scope + } + + /// Escape leaves a scope first, like deleting a search token, and closes the panel after that. + private func cancel() { + if model.scope != .all { + model.scope = .all + } else { + hide() + } + } + // MARK: Layout private func buildContentIfNeeded() { @@ -164,7 +176,13 @@ final class PanelController: NSObject, NSWindowDelegate { guard let self else { return } choose(model.selection) }, - cancel: { [weak self] in self?.hide() }, + cancel: { [weak self] in self?.cancel() }, + leaveScope: { [weak self] in + guard let self, model.scope != .all else { return false } + model.scope = .all + return true + }, + toggleScope: { [weak self] in self?.toggle($0) }, made: { [weak self] in self?.field = $0 }) let content = PanelContent( size: size, translucent: translucent, @@ -178,7 +196,8 @@ final class PanelController: NSObject, NSWindowDelegate { /// screen. For design review: nothing appears and nothing takes focus. func renderOffscreen(appearance: NSAppearance?) -> NSBitmapImageRep? { let size = preferences.size - let noop = SearchField.Commands(move: { _ in }, submit: {}, cancel: {}, made: { _ in }) + let noop = SearchField.Commands( + move: { _ in }, submit: {}, cancel: {}, leaveScope: { false }, toggleScope: { _ in }, made: { _ in }) let content = PanelContent( size: size, translucent: false, field: FieldView(model: model, preferences: preferences, commands: noop), diff --git a/Sources/CommonCommand/PanelView.swift b/Sources/CommonCommand/PanelView.swift index 7b087c4..98bbfee 100644 --- a/Sources/CommonCommand/PanelView.swift +++ b/Sources/CommonCommand/PanelView.swift @@ -12,10 +12,12 @@ struct FieldView: View { var body: some View { HStack(spacing: 10) { - Image(systemName: "magnifyingglass") + Image(systemName: model.scope.symbol) .font(.system(size: size.fieldFontSize * 0.82, weight: .medium)) - .foregroundStyle(.secondary) + .foregroundStyle(model.scope == .all ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tint)) + .frame(width: size.fieldFontSize * 1.1) SearchField(model: model, fontSize: size.fieldFontSize, commands: commands) + ScopeButtons(model: model, size: size, toggle: commands.toggleScope) } .padding(.leading, size.fieldHeight * 0.36) .padding(.trailing, size.fieldHeight * 0.3) @@ -24,6 +26,36 @@ struct FieldView: View { } } +/// Apps, Files, and Actions, as in Spotlight on macOS 26: a click or ⌘1–⌘3 narrows the search to one +/// kind, and with nothing typed lists what that kind holds. +private struct ScopeButtons: View { + let model: SearchModel + let size: PanelSize + let toggle: (Scope) -> Void + + var body: some View { + HStack(spacing: 2) { + ForEach(Scope.scoped) { scope in + let isOn = model.scope == scope + Button { + toggle(scope) + } label: { + Image(systemName: scope.symbol) + .font(.system(size: size.fieldFontSize * 0.62, weight: .medium)) + .foregroundStyle(isOn ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) + .frame(width: size.fieldHeight * 0.62, height: size.fieldHeight * 0.62) + .background(Circle().fill(isOn ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear))) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .help("\(scope.name) (⌘\(scope.key.map(String.init) ?? ""))") + .accessibilityLabel(scope.name) + .accessibilityAddTraits(isOn ? .isSelected : []) + } + } + } +} + /// The results, in sections, on their own card under the field. struct CardView: View { let model: SearchModel @@ -46,16 +78,13 @@ struct CardView: View { SectionHeader(title: entry.section.title, size: size) ForEach(Array(entry.section.rows.enumerated()), id: \.element.id) { offset, ranked in let index = entry.start + offset - ResultRow( - ranked: ranked, size: size, isSelected: index == model.selection, - shortcut: model.showsShortcuts && index < 9 ? "⌘\(index + 1)" : nil - ) - .id(index) - .onTapGesture { choose(index) } - .onContinuousHover { phase in - if case .active = phase { model.hover(index) } - } - .dragsFile(ranked.item.fileURL) + ResultRow(ranked: ranked, size: size, isSelected: index == model.selection) + .id(index) + .onTapGesture { choose(index) } + .onContinuousHover { phase in + if case .active = phase { model.hover(index) } + } + .dragsFile(ranked.item.fileURL) } } } @@ -124,7 +153,6 @@ private struct ResultRow: View { let ranked: Ranked let size: PanelSize let isSelected: Bool - let shortcut: String? var body: some View { HStack(spacing: 10) { @@ -133,7 +161,7 @@ private struct ResultRow: View { .lineLimit(1) .truncationMode(.middle) Spacer(minLength: 16) - Text(shortcut ?? ranked.item.subtitle) + Text(ranked.item.subtitle) .font(.system(size: size.rowFontSize - 2)) .foregroundStyle(isSelected ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary)) .lineLimit(1) @@ -208,6 +236,7 @@ final class Icons { guard let path = item.iconFile?.path else { return settingsIcon } return cached(path) case .command: return NSApp.applicationIconImage + case .action: return item.iconFile.map { cached($0.path) } case .calculation, .link, .webSearch: return nil } } @@ -239,6 +268,9 @@ struct SearchField: NSViewRepresentable { var move: (Int) -> Void var submit: () -> Void var cancel: () -> Void + /// ⌫ in an empty field leaves the scope; returns whether there was one to leave. + var leaveScope: () -> Bool + var toggleScope: (Scope) -> Void var made: (NSTextField) -> Void } @@ -261,6 +293,8 @@ struct SearchField: NSViewRepresentable { let font = NSFont.systemFont(ofSize: fontSize, weight: .regular) if field.font != font { field.font = font } if field.stringValue != model.query { field.stringValue = model.query } + let placeholder = model.scope == .all ? "Search" : "Search \(model.scope.name)" + if field.placeholderString != placeholder { field.placeholderString = placeholder } } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } @@ -282,6 +316,8 @@ struct SearchField: NSViewRepresentable { case #selector(NSResponder.insertBacktab(_:)): parent.commands.move(-1) case #selector(NSResponder.insertNewline(_:)): parent.commands.submit() case #selector(NSResponder.cancelOperation(_:)): parent.commands.cancel() + case #selector(NSResponder.deleteBackward(_:)) where textView.string.isEmpty: + return parent.commands.leaveScope() default: return false } return true diff --git a/Sources/CommonCommand/Preferences.swift b/Sources/CommonCommand/Preferences.swift index c68accc..78a0015 100644 --- a/Sources/CommonCommand/Preferences.swift +++ b/Sources/CommonCommand/Preferences.swift @@ -29,6 +29,7 @@ final class Preferences { var searchesFiles: Bool { didSet { defaults.set(searchesFiles, forKey: "searchesFiles") } } var calculates: Bool { didSet { defaults.set(calculates, forKey: "calculates") } } var searchesWeb: Bool { didSet { defaults.set(searchesWeb, forKey: "searchesWeb") } } + var searchesActions: Bool { didSet { defaults.set(searchesActions, forKey: "searchesActions") } } var engine: SearchEngine { didSet { defaults.set(engine.rawValue, forKey: "engine") } } var showsMenuBarItem: Bool { didSet { defaults.set(showsMenuBarItem, forKey: "showsMenuBarItem") } } @@ -38,7 +39,7 @@ final class Preferences { self.defaults = defaults defaults.register(defaults: [ "translucent": true, "visibleRows": 8, "searchesSettings": true, "searchesFiles": true, "calculates": true, - "searchesWeb": true, "showsMenuBarItem": true, + "searchesWeb": true, "searchesActions": true, "showsMenuBarItem": true, ]) hotKey = defaults.data(forKey: "hotKey").flatMap { try? JSONDecoder().decode(KeyCombo.self, from: $0) } ?? .standard @@ -50,6 +51,7 @@ final class Preferences { searchesFiles = defaults.bool(forKey: "searchesFiles") calculates = defaults.bool(forKey: "calculates") searchesWeb = defaults.bool(forKey: "searchesWeb") + searchesActions = defaults.bool(forKey: "searchesActions") engine = defaults.string(forKey: "engine").flatMap(SearchEngine.init) ?? .google showsMenuBarItem = defaults.bool(forKey: "showsMenuBarItem") } diff --git a/Sources/CommonCommand/SearchModel.swift b/Sources/CommonCommand/SearchModel.swift index 8a3ce96..259cf37 100644 --- a/Sources/CommonCommand/SearchModel.swift +++ b/Sources/CommonCommand/SearchModel.swift @@ -15,8 +15,15 @@ final class SearchModel { /// Every row in display order, which is the order the selection moves through. private(set) var results: [Ranked] = [] var selection = 0 - /// True while ⌘ is held, so each row shows its ⌘1–⌘9 shortcut. - var showsShortcuts = false + /// Everything, or only apps, files, or actions. With nothing typed, a scope lists what it holds. + var scope = Scope.all { + didSet { + guard scope != oldValue else { return } + pointerMoved = false + files = [] + update() + } + } /// Whether the pointer has really moved since the last key. Rows change under a still pointer while /// you type, and that mustn't take the selection, or Return would open whatever happened to land there. @ObservationIgnored var pointerMoved = false @@ -37,9 +44,14 @@ final class SearchModel { var selected: Item? { results.indices.contains(selection) ? results[selection].item : nil } - /// Typed something, but nothing matched and the web search is off. + /// Typed something, or chose a scope, and nothing is there. var showsNoResults: Bool { - sections.isEmpty && !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + sections.isEmpty && (scope != .all || !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + /// Whether files belong in what's shown now: in Everything if Settings says so, and always in Files. + private var includesFiles: Bool { + scope == .files || (scope == .all && preferences.searchesFiles) } func move(_ delta: Int) { @@ -61,14 +73,17 @@ final class SearchModel { compose(keepingSelection: keepingSelection) fileTask?.cancel() let text = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard preferences.searchesFiles, !text.isEmpty else { + // Nothing typed in Files lists recent files; otherwise nothing typed needs no files. + guard includesFiles, !text.isEmpty || scope == .files else { files = [] return } fileTask = Task { try? await Task.sleep(for: Self.fileDelay) guard !Task.isCancelled else { return } - let found = await Task.detached(priority: .userInitiated) { FileSearch.run(text) }.value + let found = await Task.detached(priority: .userInitiated) { + text.isEmpty ? FileSearch.recent() : FileSearch.run(text) + }.value guard !Task.isCancelled else { return } files = found compose(keepingSelection: true) @@ -79,10 +94,15 @@ final class SearchModel { let selectedID = keepingSelection && selection != 0 ? selected?.id : nil // Files from the previous keystroke stay until the new ones arrive; ranking drops those that no // longer match, so the list doesn't flicker. + var options = preferences.searchOptions + options.scope = scope + // A scope chosen on purpose shows its kind even if Settings leaves it out of Everything. + let candidates = library.candidates( + includingPanes: preferences.searchesSettings || scope == .apps, + includingActions: preferences.searchesActions || scope == .actions) sections = Results.compose( - query: query, candidates: library.candidates(includingPanes: preferences.searchesSettings), - files: preferences.searchesFiles ? files : [], usage: library.usage, now: Date(), - options: preferences.searchOptions) + query: query, candidates: candidates, files: includesFiles ? files : [], usage: library.usage, now: Date(), + options: options) results = sections.rows if let selectedID, let index = results.firstIndex(where: { $0.id == selectedID }) { selection = index diff --git a/Sources/CommonCommand/SettingsView.swift b/Sources/CommonCommand/SettingsView.swift index 8e579b0..a3f8012 100644 --- a/Sources/CommonCommand/SettingsView.swift +++ b/Sources/CommonCommand/SettingsView.swift @@ -58,7 +58,8 @@ struct SettingsView: View { Section { Toggle("System Settings", isOn: $preferences.searchesSettings) Toggle("Files and folders", isOn: $preferences.searchesFiles) - Toggle("Calculator", isOn: $preferences.calculates) + Toggle("Calculator and conversions", isOn: $preferences.calculates) + Toggle("Shortcuts and app actions", isOn: $preferences.searchesActions) Toggle("Web search", isOn: $preferences.searchesWeb) Picker("Search engine", selection: $preferences.engine) { ForEach(SearchEngine.allCases) { Text($0.name).tag($0) } @@ -67,9 +68,11 @@ struct SettingsView: View { } header: { Text("Search") } footer: { - Text("Apps are always searched. Files come from the Spotlight index of your home folder.") - .font(.callout) - .foregroundStyle(.secondary) + Text( + "Apps are always searched. Files come from the Spotlight index of your home folder. ⌘1, ⌘2, and ⌘3 search only apps, files, or actions, whatever is switched off here." + ) + .font(.callout) + .foregroundStyle(.secondary) } Section { diff --git a/Sources/CommonCommand/Snapshot.swift b/Sources/CommonCommand/Snapshot.swift index 6895657..7c8f044 100644 --- a/Sources/CommonCommand/Snapshot.swift +++ b/Sources/CommonCommand/Snapshot.swift @@ -1,6 +1,7 @@ import AppKit +import CommandKit -/// `CommonCommand --snapshot out.png [query] [--theme light|dark]` draws the panel with `query` typed into +/// `CommonCommand --snapshot out.png [query] [--theme light|dark] [--scope apps|files|actions]` draws the panel with `query` typed into /// an image and quits. It renders offscreen, so nothing appears on screen and nothing takes focus; glass /// shows as a solid fill, since only the window server can draw it. @MainActor @@ -9,6 +10,7 @@ enum Snapshot { var output: String var query: String var theme: Theme? + var scope = Scope.all } static func request(from arguments: [String]) -> Request? { @@ -19,13 +21,20 @@ enum Snapshot { theme = Theme(rawValue: rest[flag + 1]) rest.removeSubrange(flag...(flag + 1)) } + var scope = Scope.all + if let flag = rest.firstIndex(of: "--scope"), flag + 1 < rest.count { + scope = Scope(rawValue: rest[flag + 1]) ?? .all + rest.removeSubrange(flag...(flag + 1)) + } let query = rest.first { !$0.hasPrefix("--") } ?? "" - return Request(output: arguments[index + 1], query: query, theme: theme) + return Request(output: arguments[index + 1], query: query, theme: theme, scope: scope) } static func run(_ request: Request, panel: PanelController, preferences: Preferences) { // Offscreen there's no glass, so draw the solid surfaces instead of nothing. preferences.translucent = false + panel.prepareForSnapshot() + panel.model.scope = request.scope panel.model.query = request.query // Long enough for the app scan and the Spotlight file query to land. DispatchQueue.main.asyncAfter(deadline: .now() + 1) { diff --git a/Tests/CommandKitTests/ScopeTests.swift b/Tests/CommandKitTests/ScopeTests.swift new file mode 100644 index 0000000..2ece8a1 --- /dev/null +++ b/Tests/CommandKitTests/ScopeTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing + +@testable import CommandKit + +@Suite struct ScopeTests { + let now = Date(timeIntervalSince1970: 1_000_000) + let running = Actions.appActions([ + .init(name: "Safari", bundleIdentifier: "com.apple.Safari", url: URL(filePath: "/Applications/Safari.app")), + .init( + name: "Messages", bundleIdentifier: "com.apple.MobileSMS", + url: URL(filePath: "/System/Applications/Messages.app")), + ]) + let shortcuts = Actions.shortcuts(["Remind Me at Home", "Arrive", "Arrive"], icon: nil) + + func pane(_ title: String, topic: Bool = false) -> Candidate { + Candidate( + Item( + id: "pane:" + title, title: title, subtitle: "", kind: .settings, + action: .open(URL(string: "x-apple.systempreferences:x")!)), + matchesWordStarts: topic) + } + + func file(_ title: String) -> Candidate { + Candidate( + Item( + id: "file:/x/" + title, title: title, subtitle: "~/x", kind: .file, + action: .open(URL(filePath: "/x/" + title)))) + } + + func compose(_ query: String, scope: Scope, _ candidates: [Candidate], files: [Candidate] = []) -> [ResultSection] { + var options = SearchOptions() + options.scope = scope + options.locale = Locale(identifier: "en_US") + return Results.compose( + query: query, candidates: candidates, files: files, usage: Usage(), now: now, options: options) + } + + var everything: [Candidate] { [app("Safari"), app("Slack"), pane("Sound")] + running + shortcuts } + + @Test func aScopeShowsOnlyItsKind() { + #expect( + compose("s", scope: .apps, everything, files: [file("safari.txt")]).rows.allSatisfy { + [.application, .settings, .command].contains($0.item.kind) + }) + #expect( + compose("s", scope: .files, everything, files: [file("safari.txt")]).rows.map(\.item.title) == [ + "safari.txt" + ]) + #expect(compose("a", scope: .actions, everything).rows.allSatisfy { $0.item.kind == .action }) + } + + @Test func aScopeLeavesOutCalculationsAndTheWeb() { + let kinds = compose("2+2", scope: .apps, everything).rows.map(\.item.kind) + #expect(!kinds.contains(.calculation) && !kinds.contains(.webSearch)) + } + + @Test func nothingTypedInEverythingShowsNothing() { + #expect(compose("", scope: .all, everything).isEmpty) + } + + @Test func nothingTypedInAppsListsAppsAndPanesFromAToZ() { + let sections = compose("", scope: .apps, everything + [app("Notes"), pane("Low Power Mode", topic: true)]) + #expect(sections.map(\.title) == ["Applications", "System Settings"]) + #expect(sections[0].rows.map(\.item.title) == ["Notes", "Safari", "Slack"]) + // Topics would bury the panes. + #expect(sections[1].rows.map(\.item.title) == ["Sound"]) + } + + @Test func nothingTypedInFilesKeepsTheOrderGiven() { + let sections = compose("", scope: .files, everything, files: [file("b.txt"), file("a.txt")]) + #expect(sections.map(\.title) == ["Recent Files"]) + #expect(sections.rows.map(\.item.title) == ["b.txt", "a.txt"]) + } + + @Test func nothingTypedInActionsListsShortcutsThenEachAppsActionsTogether() { + let sections = compose("", scope: .actions, everything) + #expect(sections.map(\.title) == ["Shortcuts", "Running Apps"]) + #expect(sections[0].rows.map(\.item.title) == ["Arrive", "Remind Me at Home"]) + #expect(sections[1].rows.map(\.item.title) == ["Quit Messages", "Hide Messages", "Quit Safari", "Hide Safari"]) + } + + @Test func anEmptyScopeShowsNoSections() { + #expect(compose("", scope: .actions, [app("Safari")]).isEmpty) + } + + @Test func scopesHaveSpotlightsKeys() { + #expect(Scope.scoped.map(\.key) == ["1", "2", "3"]) + #expect(Scope.all.key == nil) + } +} + +@Suite struct ActionsTests { + @Test func readsTheShortcutsList() { + #expect( + Actions.shortcutNames(fromList: "New Shortcut\n Arrive \n\nRemind Me at Home\n") == [ + "New Shortcut", "Arrive", "Remind Me at Home", + ]) + } + + @Test func aShortcutListedTwiceShowsOnce() { + #expect(Actions.shortcuts(["Arrive", "Arrive"], icon: nil).count == 1) + } + + @Test func shortcutsRunByName() { + #expect(Actions.shortcuts(["Arrive"], icon: nil).first?.item.action == .shortcut("Arrive")) + } + + @Test func quitAndHideMatchByWordStartsOnly() { + let actions = Actions.appActions([ + .init(name: "Messages", bundleIdentifier: "m", url: URL(filePath: "/m.app")), + .init(name: "Safari", bundleIdentifier: "s", url: URL(filePath: "/s.app")), + ]) + let ranked = Ranker.rank(actions, query: "quit sa", usage: Usage(), now: Date(), limit: 5) + #expect(ranked.map(\.item.title) == ["Quit Safari"]) + #expect(ranked.first?.item.action == .quit("s")) + } + + @Test func actionsRankBelowAnAppWithTheSameName() { + let actions = Actions.shortcuts(["Safari Reading List"], icon: nil) + let ranked = Ranker.rank(actions + [app("Safari")], query: "safari", usage: Usage(), now: Date(), limit: 2) + #expect(ranked.first?.item.kind == .application) + } +}