diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c396a..28e6f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to this project are documented here. The format follows [Kee - 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. +- An action menu on ⌘K or a right-click, with Show in Finder, Quick Look, Copy Path, and Hide or Quit for running apps. ⌘Y opens Quick Look and ⌘⇧C copies directly. ### Changed diff --git a/README.md b/README.md index 25492b1..60d9938 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ Spotlight owns ⌘Space on a new Mac, and launchers such as Alfred often take it | ↑ ↓, ⌃P ⌃N, Tab | Move the selection | | ↩ | Open the selected result, or copy a calculation | | ⌘↩ | Show the selected file or app in Finder | +| ⌘K, or a right-click | The action menu: Open, Show in Finder, Quick Look, Copy Path, and Hide or Quit for a running app | +| ⌘Y | Quick Look the selected file beside the panel; ↑ ↓ move through the results and the preview follows, ⎋ closes it | +| ⌘⇧C | Copy the path, link, or result | | ⌘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 | diff --git a/Sources/CommandKit/ItemCommands.swift b/Sources/CommandKit/ItemCommands.swift new file mode 100644 index 0000000..471796b --- /dev/null +++ b/Sources/CommandKit/ItemCommands.swift @@ -0,0 +1,62 @@ +import Foundation + +/// What can be done with a result besides opening it: the action menu (⌘K, or a right-click), in the +/// order Finder lists the same things. +public enum ItemCommand: Equatable, Sendable { + case open + case showInFinder + case quickLook + case copyPath + case copyText(String) + case quit + case hide + + public var title: String { + switch self { + case .open: "Open" + case .showInFinder: "Show in Finder" + case .quickLook: "Quick Look" + case .copyPath: "Copy Path" + case .copyText: "Copy" + case .quit: "Quit" + case .hide: "Hide" + } + } + + /// The key that does the same from the panel, shown beside the menu item. + public var shortcut: (key: String, command: Bool, shift: Bool)? { + switch self { + case .open: ("\r", false, false) + case .showInFinder: ("\r", true, false) + case .quickLook: ("y", true, false) + case .copyPath, .copyText: ("c", true, true) + case .quit, .hide: nil + } + } + + public var symbol: String { + switch self { + case .open: "arrow.up.forward.app" + case .showInFinder: "folder" + case .quickLook: "eye" + case .copyPath, .copyText: "doc.on.doc" + case .quit: "xmark.circle" + case .hide: "eye.slash" + } + } + + /// The commands for `item`. `isRunning` adds Hide and Quit for an app that's open. A calculation's + /// only command is Copy, which is also what Return does. + public static func commands(for item: Item, isRunning: Bool) -> [ItemCommand] { + if item.fileURL != nil { + var commands: [ItemCommand] = [.open, .showInFinder, .quickLook, .copyPath] + if item.kind == .application, isRunning { commands += [.hide, .quit] } + return commands + } + switch item.action { + case .open(let url): return [.open, .copyText(url.absoluteString)] + case .copy(let text): return [.copyText(text)] + default: return [.open] + } + } +} diff --git a/Sources/CommonCommand/ActionMenuItem.swift b/Sources/CommonCommand/ActionMenuItem.swift new file mode 100644 index 0000000..2a97458 --- /dev/null +++ b/Sources/CommonCommand/ActionMenuItem.swift @@ -0,0 +1,24 @@ +import AppKit +import CommandKit + +/// One entry in the action menu: the command's name, symbol, and key, running a closure when chosen. +final class ActionMenuItem: NSMenuItem { + private let handler: () -> Void + + init(command: ItemCommand, handler: @escaping () -> Void) { + self.handler = handler + super.init(title: command.title, action: #selector(runHandler), keyEquivalent: command.shortcut?.key ?? "") + target = self + image = NSImage(systemSymbolName: command.symbol, accessibilityDescription: nil) + if let shortcut = command.shortcut { + var modifiers: NSEvent.ModifierFlags = [] + if shortcut.command { modifiers.insert(.command) } + if shortcut.shift { modifiers.insert(.shift) } + keyEquivalentModifierMask = modifiers + } + } + + required init(coder: NSCoder) { fatalError("not used from a nib") } + + @objc private func runHandler() { handler() } +} diff --git a/Sources/CommonCommand/PanelController.swift b/Sources/CommonCommand/PanelController.swift index 107b8b2..1aeb50b 100644 --- a/Sources/CommonCommand/PanelController.swift +++ b/Sources/CommonCommand/PanelController.swift @@ -64,6 +64,7 @@ final class PanelController: NSObject, NSWindowDelegate { panel.delegate = self panel.onKeyEquivalent = { [weak self] in self?.keyEquivalent($0) ?? false } panel.onPointerMove = { [weak self] in self?.model.pointerMoved = true } + model.onSelectionChange = { [weak self] in self?.updatePreview() } model.onResultsChange = { [weak self] in self?.resize() } library.onChange = { [weak self] in guard let self, panel.isVisible else { return } @@ -105,6 +106,7 @@ final class PanelController: NSObject, NSWindowDelegate { } func hide() { + closePreview() panel.orderOut(nil) } @@ -112,6 +114,38 @@ final class PanelController: NSObject, NSWindowDelegate { hide() } + // MARK: Preview + + /// Quick Look for the selected file, in a panel beside this one. It never takes focus, so typing, the + /// arrows, and Escape stay in the field, and the app you were in stays active throughout. + private var preview: PreviewPanel? + + private func toggleQuickLook() { + if preview != nil { + closePreview() + } else if let url = model.selected?.fileURL { + let preview = PreviewPanel() + preview.appearance = panel.appearance + preview.show(url, beside: panel.frame, size: preferences.size) + panel.addChildWindow(preview, ordered: .above) + self.preview = preview + } + } + + /// Follows the selection while the preview is open; a result without a file closes it. + private func updatePreview() { + guard let preview else { return } + guard let url = model.selected?.fileURL else { return closePreview() } + preview.show(url, beside: panel.frame, size: preferences.size) + } + + private func closePreview() { + guard let preview else { return } + panel.removeChildWindow(preview) + preview.orderOut(nil) + self.preview = nil + } + func preferencesChanged() { guard isVisible else { return } buildContentIfNeeded() @@ -129,9 +163,86 @@ final class PanelController: NSObject, NSWindowDelegate { perform?(item, reveal) } + // MARK: Commands + + /// What the action menu offers for the result at `index`. + func commands(for item: Item) -> [ItemCommand] { + let isRunning = + item.kind == .application + && NSWorkspace.shared.runningApplications.contains { + $0.bundleURL?.standardizedFileURL == item.fileURL?.standardizedFileURL + } + return ItemCommand.commands(for: item, isRunning: isRunning) + } + + func run(_ command: ItemCommand, on index: Int) { + guard model.results.indices.contains(index) else { return } + let item = model.results[index].item + switch command { + case .open: choose(index) + case .showInFinder: choose(index, reveal: true) + case .quickLook: + model.selection = index + toggleQuickLook() + case .copyPath: + guard let url = item.fileURL else { return } + copy(url.path, url: url) + case .copyText(let text): copy(text, url: nil) + case .quit, .hide: + let apps = NSWorkspace.shared.runningApplications.filter { + $0.bundleURL?.standardizedFileURL == item.fileURL?.standardizedFileURL + } + hide() + for app in apps { + if command == .quit { app.terminate() } else { app.hide() } + } + } + } + + /// Copies, and closes the panel the way Return does after a calculation. + private func copy(_ text: String, url: URL?) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + // A file goes on as a file too, so pasting into Finder or Mail attaches it. + if let url { pasteboard.writeObjects([url as NSURL]) } + pasteboard.setString(text, forType: .string) + hide() + } + + /// ⌘K: the action menu for the selected result, opened under the field like a pull-down. + private func showActionMenu() { + guard let item = model.selected, let content = panel.contentView else { return } + let index = model.selection + let menu = NSMenu() + for command in commands(for: item) { + let entry = ActionMenuItem(command: command) { [weak self] in self?.run(command, on: index) } + menu.addItem(entry) + } + let size = preferences.size + menu.popUp( + positioning: menu.items.first, + at: NSPoint(x: size.width - size.fieldHeight * 2.4, y: size.fieldHeight + size.gap), in: content) + } + private func keyEquivalent(_ event: NSEvent) -> Bool { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard flags == .command, let key = event.charactersIgnoringModifiers else { return false } + guard let key = event.charactersIgnoringModifiers?.lowercased() else { return false } + if flags == [.command, .shift], key == "c", let item = model.selected { + if let copy = commands(for: item).first(where: { if case .copyText = $0 { true } else { $0 == .copyPath } }) + { + run(copy, on: model.selection) + } + return true + } + guard flags == .command else { return false } + if key == "k" { + showActionMenu() + return true + } + if key == "y" { + toggleQuickLook() + return true + } if key == "\r" { choose(model.selection, reveal: true) return true @@ -153,9 +264,12 @@ final class PanelController: NSObject, NSWindowDelegate { model.scope = model.scope == scope ? .all : scope } - /// Escape leaves a scope first, like deleting a search token, and closes the panel after that. + /// Escape closes the preview first, then leaves a scope, like deleting a search token, and closes the + /// panel after that. private func cancel() { - if model.scope != .all { + if preview != nil { + closePreview() + } else if model.scope != .all { model.scope = .all } else { hide() @@ -187,7 +301,10 @@ final class PanelController: NSObject, NSWindowDelegate { let content = PanelContent( size: size, translucent: translucent, field: FieldView(model: model, preferences: preferences, commands: commands), - card: CardView(model: model, preferences: preferences) { [weak self] in self?.choose($0) }) + card: CardView( + model: model, preferences: preferences, choose: { [weak self] in self?.choose($0) }, + commands: { [weak self] in self?.commands(for: $0) ?? [] }, + run: { [weak self] in self?.run($0, on: $1) })) self.content = content panel.contentView = content } @@ -279,6 +396,7 @@ final class PanelController: NSObject, NSWindowDelegate { NSRect(x: panel.frame.midX - size.width / 2, y: top - height, width: size.width, height: height), display: false) refreshShadow() + updatePreview() } /// The shadow follows the window's shape as last drawn, so it's recomputed once the new frame has diff --git a/Sources/CommonCommand/PanelView.swift b/Sources/CommonCommand/PanelView.swift index 98bbfee..6934967 100644 --- a/Sources/CommonCommand/PanelView.swift +++ b/Sources/CommonCommand/PanelView.swift @@ -61,6 +61,9 @@ struct CardView: View { let model: SearchModel let preferences: Preferences let choose: (Int) -> Void + /// The action menu's commands for a result, and running one; a right-click shows the same menu as ⌘K. + var commands: (Item) -> [ItemCommand] = { _ in [] } + var run: (ItemCommand, Int) -> Void = { _, _ in } private var size: PanelSize { preferences.size } @@ -85,6 +88,11 @@ struct CardView: View { if case .active = phase { model.hover(index) } } .dragsFile(ranked.item.fileURL) + .contextMenu { + ForEach(Array(commands(ranked.item).enumerated()), id: \.offset) { _, command in + Button(command.title, systemImage: command.symbol) { run(command, index) } + } + } } } } diff --git a/Sources/CommonCommand/PreviewPanel.swift b/Sources/CommonCommand/PreviewPanel.swift new file mode 100644 index 0000000..46e7927 --- /dev/null +++ b/Sources/CommonCommand/PreviewPanel.swift @@ -0,0 +1,57 @@ +import AppKit +import CommandKit +import QuickLookUI + +/// Quick Look beside the launcher, as Spotlight shows a preview. A non-activating panel that can't take +/// focus: the keyboard stays in the search field, so the selection moves on with the preview following, +/// and nothing changes which app is active. +final class PreviewPanel: NSPanel { + private let view: QLPreviewView + + init() { + view = QLPreviewView(frame: .zero, style: .normal) + super.init( + contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true) + isFloatingPanel = true + level = .modalPanel + isOpaque = false + backgroundColor = .clear + hasShadow = true + animationBehavior = .none + isReleasedWhenClosed = false + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] + + let clip = NSView() + clip.wantsLayer = true + clip.layer?.cornerCurve = .continuous + clip.layer?.masksToBounds = true + view.autoresizingMask = [.width, .height] + clip.addSubview(view) + contentView = clip + } + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } + + static let width: CGFloat = 440 + static let gap: CGFloat = 10 + + /// Shows `url`, top-aligned with `panel`, on whichever side of it has room on its screen. + func show(_ url: URL, beside panel: NSRect, size: PanelSize) { + if (view.previewItem as? NSURL) as URL? != url { view.previewItem = url as NSURL } + let height = max(panel.height, 380) + let screen = NSScreen.screens.first { $0.frame.intersects(panel) }?.visibleFrame ?? panel + var x = panel.maxX + Self.gap + if x + Self.width > screen.maxX { x = panel.minX - Self.gap - Self.width } + let radius = size.cardRadius + contentView?.layer?.cornerRadius = radius + // A layer's colour is fixed when set, so resolve it in this panel's light or dark appearance. + effectiveAppearance.performAsCurrentDrawingAppearance { + contentView?.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + } + setFrame(NSRect(x: x, y: panel.maxY - height, width: Self.width, height: height), display: true) + contentView?.subviews.first?.frame = contentView?.bounds ?? .zero + orderFront(nil) + invalidateShadow() + } +} diff --git a/Sources/CommonCommand/SearchModel.swift b/Sources/CommonCommand/SearchModel.swift index abd3cdd..5e8c87d 100644 --- a/Sources/CommonCommand/SearchModel.swift +++ b/Sources/CommonCommand/SearchModel.swift @@ -15,7 +15,10 @@ final class SearchModel { /// Every row in display order, which is the order the selection moves through. private(set) var results: [Ranked] = [] var selection = 0 { - didSet { announceSelection() } + didSet { + announceSelection() + if selection != oldValue { onSelectionChange?() } + } } /// Everything, or only apps, files, or actions. With nothing typed, a scope lists what it holds. var scope = Scope.all { @@ -31,6 +34,7 @@ final class SearchModel { @ObservationIgnored var pointerMoved = false @ObservationIgnored var onResultsChange: (() -> Void)? + @ObservationIgnored var onSelectionChange: (() -> Void)? @ObservationIgnored private let library: Library @ObservationIgnored private let preferences: Preferences @ObservationIgnored private var files: [Candidate] = [] diff --git a/Tests/CommandKitTests/ItemCommandTests.swift b/Tests/CommandKitTests/ItemCommandTests.swift new file mode 100644 index 0000000..98b64cf --- /dev/null +++ b/Tests/CommandKitTests/ItemCommandTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing + +@testable import CommandKit + +@Suite struct ItemCommandTests { + func item(_ kind: Kind, _ action: Action) -> Item { + Item(id: "x", title: "x", subtitle: "", kind: kind, action: action) + } + + @Test func filesCanBeShownPreviewedAndCopied() { + let file = item(.file, .open(URL(filePath: "/Users/me/a.txt"))) + #expect(ItemCommand.commands(for: file, isRunning: false) == [.open, .showInFinder, .quickLook, .copyPath]) + } + + @Test func aRunningAppCanAlsoBeHiddenOrQuit() { + let app = item(.application, .open(URL(filePath: "/Applications/Safari.app"))) + #expect(ItemCommand.commands(for: app, isRunning: false) == [.open, .showInFinder, .quickLook, .copyPath]) + #expect(ItemCommand.commands(for: app, isRunning: true).suffix(2) == [.hide, .quit]) + } + + @Test func aRunningFileIsNotAnApp() { + let file = item(.file, .open(URL(filePath: "/Users/me/a.txt"))) + #expect(!ItemCommand.commands(for: file, isRunning: true).contains(.quit)) + } + + @Test func aLinkOrPaneCopiesItsAddress() { + let link = item(.link, .open(URL(string: "https://github.com")!)) + #expect(ItemCommand.commands(for: link, isRunning: false) == [.open, .copyText("https://github.com")]) + } + + @Test func aCalculationOnlyCopies() { + #expect(ItemCommand.commands(for: item(.calculation, .copy("4")), isRunning: false) == [.copyText("4")]) + } + + @Test func otherActionsOnlyRun() { + #expect(ItemCommand.commands(for: item(.action, .shortcut("Arrive")), isRunning: false) == [.open]) + } + + @Test func menuShortcutsMatchThePanelsKeys() { + #expect(ItemCommand.showInFinder.shortcut?.key == "\r" && ItemCommand.showInFinder.shortcut?.command == true) + #expect(ItemCommand.quickLook.shortcut?.key == "y") + #expect(ItemCommand.copyPath.shortcut?.shift == true) + } +}