From b0723ebd5c17429d48976571e987f94ad8b8cf0f Mon Sep 17 00:00:00 2001 From: Oddur Sigurdsson Date: Fri, 16 Oct 2026 22:56:46 -0400 Subject: [PATCH] feat(settings): preview the panel and warn about shortcut clashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the theme or size in Settings meant closing Settings to see the result. Worse, recording a shortcut that Alfred or Spotlight already holds failed silently: RegisterEventHotKey accepted ⌘Space while Alfred held it, so only one app opened and nothing said why. Settings now shows the real panel as a preview, and reads Spotlight's and Alfred's shortcuts to name a clash and where to fix it. --- CHANGELOG.md | 3 + README.md | 4 +- Sources/CommandKit/Conflicts.swift | 52 +++++++++++++++++ Sources/CommonCommand/AppController.swift | 28 ++++++++- Sources/CommonCommand/HotKey.swift | 16 ++++++ Sources/CommonCommand/PanelController.swift | 28 +++++++++ Sources/CommonCommand/PanelPreview.swift | 63 +++++++++++++++++++++ Sources/CommonCommand/SearchModel.swift | 21 ++++++- Sources/CommonCommand/SettingsView.swift | 36 ++++++++++-- Sources/CommonCommand/Snapshot.swift | 35 +++++++++--- Tests/CommandKitTests/ConflictTests.swift | 56 ++++++++++++++++++ 11 files changed, 324 insertions(+), 18 deletions(-) create mode 100644 Sources/CommandKit/Conflicts.swift create mode 100644 Sources/CommonCommand/PanelPreview.swift create mode 100644 Tests/CommandKitTests/ConflictTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index da9afd0..08c396a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ All notable changes to this project are documented here. The format follows [Kee - "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. +- A live preview of the panel at the top of Settings. +- A warning in Settings and the menu bar menu when the shortcut is also Spotlight's or Alfred's, saying where to change theirs. macOS accepts both without an error, so the clash was silent. +- VoiceOver reads the selected result as it changes. ### Changed diff --git a/README.md b/README.md index a9bd40c..25492b1 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Requires macOS 14 or later. ### Using ⌘Space -Spotlight owns ⌘Space. To give it to Common Command, turn off **Show Spotlight search** in **System Settings › Keyboard › Keyboard Shortcuts › Spotlight**, then record ⌘Space in Common Command's Settings. Settings has a button that opens the right page. +Spotlight owns ⌘Space on a new Mac, and launchers such as Alfred often take it. macOS lets two apps register the same shortcut without an error, and then only one of them opens, so Common Command reads Spotlight's and Alfred's shortcuts and tells you in Settings and the menu bar menu when yours clashes, and where to change theirs. To use ⌘Space, turn off **Show Spotlight search** in **System Settings › Keyboard › Keyboard Shortcuts › Spotlight** (or change Alfred's hotkey in **Alfred Preferences › General**), then record ⌘Space in Common Command's Settings. ## Usage @@ -64,7 +64,7 @@ Each of these except apps can be turned off in Settings. The last query comes ba ## Settings - **Shortcut**: click and press a new one. Escape cancels; Delete restores ⌥Space. -- **Appearance**: theme (System, Light, Dark), size (Small, Medium, Large), translucency, and how many results show before the list scrolls. +- **Appearance**: theme (System, Light, Dark), size (Small, Medium, Large), translucency, and how many results show before the list scrolls. A preview of the panel at the top of Settings shows each change as you make it. - **Search**: System Settings, files, calculator, and web search on or off, and the search engine. - **General**: show in the menu bar, and open at login. With the menu bar item hidden, open Common Command from Finder to get back to Settings. diff --git a/Sources/CommandKit/Conflicts.swift b/Sources/CommandKit/Conflicts.swift new file mode 100644 index 0000000..8a43304 --- /dev/null +++ b/Sources/CommandKit/Conflicts.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Other apps' global shortcuts, read from where they keep them. macOS lets two apps register the same +/// shortcut without an error, so the only way to warn about a clash is to look. +public enum Conflicts { + public struct Holder: Equatable, Sendable { + public var owner: String + public var combo: KeyCombo + /// Where to change it, in words. + public var whereToChange: String + } + + /// Spotlight's two shortcuts from `com.apple.symbolichotkeys`: 64 shows Spotlight, 65 its Finder window. + public static func spotlight(_ symbolicHotKeys: [String: Any]) -> [Holder] { + let names = ["64": "Spotlight", "65": "Spotlight's Finder search"] + return names.keys.sorted().compactMap { id in + guard let entry = symbolicHotKeys[id] as? [String: Any], (entry["enabled"] as? NSNumber)?.boolValue == true, + let parameters = (entry["value"] as? [String: Any])?["parameters"] as? [NSNumber], parameters.count == 3 + else { return nil } + return Holder( + owner: names[id]!, + combo: KeyCombo( + keyCode: parameters[1].uint16Value, + modifiers: KeyCombo.Modifiers(rawValue: parameters[2].uintValue)), + whereToChange: "System Settings › Keyboard › Keyboard Shortcuts › Spotlight") + } + } + + /// Where Alfred keeps this Mac's hotkey, from its `prefs.json`: the preferences folder (which may be + /// synced elsewhere) and this Mac's subfolder. + public static func alfredHotkeyFile(prefsJSON: Data) -> URL? { + guard let json = try? JSONSerialization.jsonObject(with: prefsJSON) as? [String: Any], + let folder = json["current"] as? String, let hash = json["localhash"] as? String + else { return nil } + return URL(filePath: folder).appending(path: "preferences/local/\(hash)/hotkey/prefs.plist") + } + + public static func alfred(_ hotkeyPrefs: [String: Any]) -> Holder? { + guard let main = hotkeyPrefs["default"] as? [String: Any], let key = main["key"] as? NSNumber, + let modifiers = main["mod"] as? NSNumber + else { return nil } + return Holder( + owner: "Alfred", + combo: KeyCombo(keyCode: key.uint16Value, modifiers: KeyCombo.Modifiers(rawValue: modifiers.uintValue)), + whereToChange: "Alfred Preferences › General › Alfred Hotkey") + } + + /// The first app that also holds `combo`. + public static func holder(of combo: KeyCombo, among holders: [Holder]) -> Holder? { + holders.first { $0.combo == combo } + } +} diff --git a/Sources/CommonCommand/AppController.swift b/Sources/CommonCommand/AppController.swift index 154a9fb..bd8164b 100644 --- a/Sources/CommonCommand/AppController.swift +++ b/Sources/CommonCommand/AppController.swift @@ -10,10 +10,18 @@ final class AppController: NSObject { let preferences: Preferences private let library = Library() private let hotKey = HotKey() - private lazy var panel = PanelController(preferences: preferences, library: library) + private(set) lazy var panel = PanelController(preferences: preferences, library: library) private lazy var settings = SettingsWindow(controller: self) private var statusItem: NSStatusItem? private(set) var hotKeyProblem: String? + /// Spotlight's and Alfred's shortcuts, read when the shortcut is set and when Settings opens. + private(set) var otherShortcuts: [Conflicts.Holder] = [] + + /// Another app that also answers to our shortcut. macOS accepts both without complaint, and then only + /// one of them opens. + var hotKeyConflict: Conflicts.Holder? { + Conflicts.holder(of: preferences.hotKey, among: otherShortcuts) + } init(snapshot: Snapshot.Request? = nil) { // A snapshot mustn't change the real settings. @@ -33,7 +41,7 @@ final class AppController: NSObject { panel.perform = { [weak self] item, reveal in self?.perform(item, reveal: reveal) } hotKey.onPress = { [weak self] in self?.panel.toggle() } if let snapshot { - Snapshot.run(snapshot, panel: panel, preferences: preferences) + Snapshot.run(snapshot, controller: self) return } registerHotKey() @@ -49,7 +57,12 @@ final class AppController: NSObject { // MARK: Shortcut + func refreshOtherShortcuts() { + otherShortcuts = otherAppsShortcuts() + } + func registerHotKey() { + refreshOtherShortcuts() do { try hotKey.register(preferences.hotKey) hotKeyProblem = nil @@ -67,6 +80,8 @@ final class AppController: NSObject { // MARK: Settings @objc func showSettings() { + // Spotlight's or Alfred's shortcut may have changed since; the warning in Settings should be current. + refreshOtherShortcuts() panel.hide() settings.show() } @@ -97,7 +112,14 @@ final class AppController: NSObject { let open = NSMenuItem(title: "Open Common Command", action: #selector(showPanel), keyEquivalent: "") open.target = self menu.addItem(open) - if let hotKeyProblem { + if let conflict = hotKeyConflict { + let problem = NSMenuItem( + title: "\(preferences.hotKey.displayName) also opens \(conflict.owner)", + action: #selector(showSettings), + keyEquivalent: "") + problem.target = self + menu.addItem(problem) + } else if let hotKeyProblem { let problem = NSMenuItem(title: hotKeyProblem, action: nil, keyEquivalent: "") problem.isEnabled = false menu.addItem(problem) diff --git a/Sources/CommonCommand/HotKey.swift b/Sources/CommonCommand/HotKey.swift index 6273d32..be4dcf3 100644 --- a/Sources/CommonCommand/HotKey.swift +++ b/Sources/CommonCommand/HotKey.swift @@ -76,3 +76,19 @@ func keyName(_ keyCode: UInt16) -> String? { extension KeyCombo { var displayName: String { display(keyName: keyName) } } + +/// The global shortcuts Spotlight and Alfred hold on this Mac, read from their preferences. Missing or +/// unreadable files just mean nothing to warn about. +func otherAppsShortcuts() -> [Conflicts.Holder] { + var holders: [Conflicts.Holder] = [] + if let symbolic = UserDefaults(suiteName: "com.apple.symbolichotkeys")?.dictionary(forKey: "AppleSymbolicHotKeys") { + holders += Conflicts.spotlight(symbolic) + } + let alfred = URL.applicationSupportDirectory.appending(path: "Alfred/prefs.json") + if let data = try? Data(contentsOf: alfred), let file = Conflicts.alfredHotkeyFile(prefsJSON: data), + let prefs = NSDictionary(contentsOf: file) as? [String: Any], let holder = Conflicts.alfred(prefs) + { + holders.append(holder) + } + return holders +} diff --git a/Sources/CommonCommand/PanelController.swift b/Sources/CommonCommand/PanelController.swift index 2d43e10..107b8b2 100644 --- a/Sources/CommonCommand/PanelController.swift +++ b/Sources/CommonCommand/PanelController.swift @@ -219,6 +219,34 @@ final class PanelController: NSObject, NSWindowDelegate { return rep } + /// A second model for the preview in Settings, searching "s" so it shows a few sections. + private(set) lazy var previewModel: SearchModel = { + let model = SearchModel(library: library, preferences: preferences) + model.query = "s" + return model + }() + + /// The panel as it looks with the current settings, for Settings to show. It doesn't take input. + func makePreview() -> PanelContent { + let noop = SearchField.Commands( + move: { _ in }, submit: {}, cancel: {}, leaveScope: { false }, toggleScope: { _ in }, + made: { field in + field.isEditable = false + field.isSelectable = false + field.refusesFirstResponder = true + }) + return PanelContent( + size: preferences.size, translucent: preferences.translucent, + field: FieldView(model: previewModel, preferences: preferences, commands: noop), + card: CardView(model: previewModel, preferences: preferences) { _ in }) + } + + func previewHeight() -> CGFloat { + let size = preferences.size + return size.height( + rows: previewModel.results.count, headers: previewModel.sections.count, visible: preferences.visibleRows) + } + /// The panel's height for what the model shows now. private var height: CGFloat { let size = preferences.size diff --git a/Sources/CommonCommand/PanelPreview.swift b/Sources/CommonCommand/PanelPreview.swift new file mode 100644 index 0000000..2a0257c --- /dev/null +++ b/Sources/CommonCommand/PanelPreview.swift @@ -0,0 +1,63 @@ +import AppKit +import CommandKit +import SwiftUI + +/// The panel at the top of Settings, drawn by the same views as the real one, so theme, size, +/// translucency, and the number of results show as you change them. +struct PanelPreview: View { + let panel: PanelController + let size: PanelSize + let translucent: Bool + let theme: Theme + let visibleRows: Int + /// The width Settings has room for; the preview scales down to it. + let width: CGFloat + + var body: some View { + let height = panel.previewHeight() + let scale = min(1, width / size.width) + PanelPreviewView(panel: panel, size: size, translucent: translucent, theme: theme, height: height) + .frame(width: size.width * scale, height: height * scale) + .allowsHitTesting(false) + .accessibilityElement() + .accessibilityLabel("Preview of the panel") + } +} + +/// Holds the panel's content at full size inside a frame scaled to fit, by giving the view smaller +/// bounds than frame: AppKit scales everything inside, glass included. +private struct PanelPreviewView: NSViewRepresentable { + let panel: PanelController + let size: PanelSize + let translucent: Bool + let theme: Theme + let height: CGFloat + + final class Container: NSView { + var built: (PanelSize, Bool)? + var content: PanelContent? + var fullSize = NSSize.zero + + override func layout() { + super.layout() + guard fullSize.width > 0 else { return } + bounds = NSRect(origin: .zero, size: fullSize) + content?.frame = bounds + } + } + + func makeNSView(context: Context) -> Container { Container() } + + func updateNSView(_ container: Container, context: Context) { + if container.built.map({ $0 != (size, translucent) }) ?? true { + container.content?.removeFromSuperview() + let content = panel.makePreview() + container.addSubview(content) + container.content = content + container.built = (size, translucent) + } + container.appearance = theme.appearance + container.fullSize = NSSize(width: size.width, height: height) + container.needsLayout = true + } +} diff --git a/Sources/CommonCommand/SearchModel.swift b/Sources/CommonCommand/SearchModel.swift index 259cf37..abd3cdd 100644 --- a/Sources/CommonCommand/SearchModel.swift +++ b/Sources/CommonCommand/SearchModel.swift @@ -1,5 +1,5 @@ +import AppKit import CommandKit -import Foundation /// The query, its results, and which one is selected. @MainActor @Observable @@ -14,7 +14,9 @@ final class SearchModel { private(set) var sections: [ResultSection] = [] /// Every row in display order, which is the order the selection moves through. private(set) var results: [Ranked] = [] - var selection = 0 + var selection = 0 { + didSet { announceSelection() } + } /// Everything, or only apps, files, or actions. With nothing typed, a scope lists what it holds. var scope = Scope.all { didSet { @@ -110,5 +112,20 @@ final class SearchModel { selection = 0 } onResultsChange?() + announceSelection() + } + + @ObservationIgnored private var announcedID: String? + + /// VoiceOver reads the selected result whenever it changes, by key or because typing changed the top + /// result, so the list can be used without looking at it. + private func announceSelection() { + guard NSWorkspace.shared.isVoiceOverEnabled, let item = selected, item.id != announcedID else { return } + announcedID = item.id + let section = sections.first { $0.rows.contains { $0.id == item.id } }?.title + let words = [item.title, item.subtitle, section ?? ""].filter { !$0.isEmpty }.joined(separator: ", ") + NSAccessibility.post( + element: NSApp as Any, notification: .announcementRequested, + userInfo: [.announcement: words, .priority: NSAccessibilityPriorityLevel.high.rawValue]) } } diff --git a/Sources/CommonCommand/SettingsView.swift b/Sources/CommonCommand/SettingsView.swift index a3f8012..005c37b 100644 --- a/Sources/CommonCommand/SettingsView.swift +++ b/Sources/CommonCommand/SettingsView.swift @@ -10,11 +10,22 @@ struct SettingsView: View { @State private var openAtLogin = LoginItem.isEnabled @State private var loginError: String? + private static let commandSpace = KeyCombo(keyCode: 49, modifiers: .command) private static let keyboardShortcuts = URL( string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")! var body: some View { Form { + Section { + PanelPreview( + panel: controller.panel, size: preferences.size, translucent: preferences.translucent, + theme: preferences.theme, visibleRows: preferences.visibleRows, width: 400 + ) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .listRowBackground(Color.clear) + } + Section { LabeledContent("Open Common Command") { ShortcutRecorder(controller: controller, combo: $preferences.hotKey) @@ -23,13 +34,30 @@ struct SettingsView: View { Text("Shortcut") } footer: { VStack(alignment: .leading, spacing: 6) { - if let problem = controller.hotKeyProblem { + if let conflict = Conflicts.holder(of: preferences.hotKey, among: controller.otherShortcuts) { + Text( + "\(preferences.hotKey.displayName) also opens \(conflict.owner), so only one of them will. Change it in \(conflict.whereToChange), or choose another shortcut here." + ) + .foregroundStyle(.orange) + } else if let problem = controller.hotKeyProblem { Text(problem).foregroundStyle(.red) } - Text("To use ⌘Space, first turn off Spotlight's shortcut in Keyboard Shortcuts.") + if let spaceHolder = Conflicts.holder(of: Self.commandSpace, among: controller.otherShortcuts), + preferences.hotKey != Self.commandSpace + { + Text( + "⌘Space belongs to \(spaceHolder.owner). To use it here, change it in \(spaceHolder.whereToChange) first." + ) .foregroundStyle(.secondary) - Button("Open Keyboard Shortcuts…") { NSWorkspace.shared.open(Self.keyboardShortcuts) } - .buttonStyle(.link) + } + // The button only helps when the clash is Spotlight's; Alfred's is changed in Alfred. + let involved = [preferences.hotKey, Self.commandSpace].compactMap { + Conflicts.holder(of: $0, among: controller.otherShortcuts) + } + if involved.contains(where: { $0.owner.hasPrefix("Spotlight") }) { + Button("Open Keyboard Shortcuts…") { NSWorkspace.shared.open(Self.keyboardShortcuts) } + .buttonStyle(.link) + } } .font(.callout) } diff --git a/Sources/CommonCommand/Snapshot.swift b/Sources/CommonCommand/Snapshot.swift index 7c8f044..ad74632 100644 --- a/Sources/CommonCommand/Snapshot.swift +++ b/Sources/CommonCommand/Snapshot.swift @@ -1,7 +1,8 @@ import AppKit import CommandKit +import SwiftUI -/// `CommonCommand --snapshot out.png [query] [--theme light|dark] [--scope apps|files|actions]` draws the panel with `query` typed into +/// `CommonCommand --snapshot out.png [query] [--theme light|dark] [--scope apps|files|actions] [--settings]` 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 @@ -11,6 +12,8 @@ enum Snapshot { var query: String var theme: Theme? var scope = Scope.all + /// Draw the Settings window instead of the panel. + var settings = false } static func request(from arguments: [String]) -> Request? { @@ -26,11 +29,15 @@ enum Snapshot { scope = Scope(rawValue: rest[flag + 1]) ?? .all rest.removeSubrange(flag...(flag + 1)) } + let settings = rest.contains("--settings") let query = rest.first { !$0.hasPrefix("--") } ?? "" - return Request(output: arguments[index + 1], query: query, theme: theme, scope: scope) + return Request(output: arguments[index + 1], query: query, theme: theme, scope: scope, settings: settings) } - static func run(_ request: Request, panel: PanelController, preferences: Preferences) { + static func run(_ request: Request, controller: AppController) { + let panel = controller.panel + let preferences = controller.preferences + controller.refreshOtherShortcuts() // Offscreen there's no glass, so draw the solid surfaces instead of nothing. preferences.translucent = false panel.prepareForSnapshot() @@ -41,10 +48,10 @@ enum Snapshot { MainActor.assumeIsolated { panel.model.update(keepingSelection: true) let appearance = (request.theme ?? .system).appearance - guard - let png = panel.renderOffscreen(appearance: appearance)?.representation( - using: .png, properties: [:]) - else { + let image = + request.settings + ? renderSettings(controller, appearance: appearance) : panel.renderOffscreen(appearance: appearance) + guard let png = image?.representation(using: .png, properties: [:]) else { print("snapshot: couldn't render") exit(1) } @@ -59,4 +66,18 @@ enum Snapshot { } } } + + private static func renderSettings(_ controller: AppController, appearance: NSAppearance?) -> NSBitmapImageRep? { + let view = NSHostingView(rootView: SettingsView(controller: controller, preferences: controller.preferences)) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 460, height: 720), styleMask: .borderless, backing: .buffered, + defer: false) + window.appearance = appearance + window.contentView = view + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return nil } + view.cacheDisplay(in: view.bounds, to: rep) + return rep + } } diff --git a/Tests/CommandKitTests/ConflictTests.swift b/Tests/CommandKitTests/ConflictTests.swift new file mode 100644 index 0000000..8f2f7d8 --- /dev/null +++ b/Tests/CommandKitTests/ConflictTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing + +@testable import CommandKit + +@Suite struct ConflictTests { + let commandSpace = KeyCombo(keyCode: 49, modifiers: .command) + + /// As `com.apple.symbolichotkeys` stores them on a Mac where Spotlight moved to ⌥⌘Space. + let symbolic: [String: Any] = [ + "64": [ + "enabled": NSNumber(value: 1), + "value": ["parameters": [32, 49, 1_572_864].map(NSNumber.init), "type": "standard"], + ], + "65": [ + "enabled": NSNumber(value: 0), + "value": ["parameters": [32, 49, 1_048_576].map(NSNumber.init), "type": "standard"], + ], + "60": [ + "enabled": NSNumber(value: 1), + "value": ["parameters": [32, 49, 262_144].map(NSNumber.init), "type": "standard"], + ], + ] + + @Test func readsSpotlightsEnabledShortcutsOnly() { + let holders = Conflicts.spotlight(symbolic) + #expect(holders.map(\.owner) == ["Spotlight"]) + #expect(holders.first?.combo == KeyCombo(keyCode: 49, modifiers: [.command, .option])) + } + + @Test func readsAlfredsHotkey() { + let prefs: [String: Any] = [ + "default": ["key": NSNumber(value: 49), "mod": NSNumber(value: 1_048_576), "string": " "] + ] + #expect(Conflicts.alfred(prefs)?.combo == commandSpace) + #expect(Conflicts.alfred([:]) == nil) + } + + @Test func findsAlfredsFileThroughItsPrefsJSON() { + let json = #"{"current": "/Users/me/Dropbox/Alfred.alfredpreferences", "localhash": "abc123"}"# + #expect( + Conflicts.alfredHotkeyFile(prefsJSON: Data(json.utf8))?.path + == "/Users/me/Dropbox/Alfred.alfredpreferences/preferences/local/abc123/hotkey/prefs.plist") + #expect(Conflicts.alfredHotkeyFile(prefsJSON: Data("not json".utf8)) == nil) + } + + @Test func namesWhoElseHoldsAShortcut() { + let alfred = Conflicts.Holder(owner: "Alfred", combo: commandSpace, whereToChange: "Alfred") + let holders = Conflicts.spotlight(symbolic) + [alfred] + #expect(Conflicts.holder(of: commandSpace, among: holders)?.owner == "Alfred") + #expect( + Conflicts.holder(of: KeyCombo(keyCode: 49, modifiers: [.command, .option]), among: holders)?.owner + == "Spotlight") + #expect(Conflicts.holder(of: KeyCombo.standard, among: holders) == nil) + } +}