From ae873ddc17462fece1c2ccb7f9cc7de1339617a7 Mon Sep 17 00:00:00 2001 From: Oddur Sigurdsson Date: Fri, 16 Oct 2026 22:49:07 -0400 Subject: [PATCH 1/2] feat(panel): add an action menu and Quick Look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Results could only be opened or revealed. Finder and Spotlight let you preview a file, copy its path, or quit the app you found, and a launcher that can't is a step backwards. The menu is a native NSMenu on ⌘K or a right-click, and Quick Look is the system panel. --- CHANGELOG.md | 1 + README.md | 3 + Sources/CommandKit/ItemCommands.swift | 62 +++++++ Sources/CommonCommand/ActionMenuItem.swift | 24 +++ Sources/CommonCommand/PanelController.swift | 166 ++++++++++++++++++- Sources/CommonCommand/PanelView.swift | 8 + Tests/CommandKitTests/ItemCommandTests.swift | 45 +++++ 7 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 Sources/CommandKit/ItemCommands.swift create mode 100644 Sources/CommonCommand/ActionMenuItem.swift create mode 100644 Tests/CommandKitTests/ItemCommandTests.swift 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..763c14a 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; ↑ ↓ keep moving through the results | +| ⌘⇧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..c475d3c 100644 --- a/Sources/CommonCommand/PanelController.swift +++ b/Sources/CommonCommand/PanelController.swift @@ -1,5 +1,6 @@ import AppKit import CommandKit +import QuickLookUI import SwiftUI /// A borderless panel that takes typing without activating the app, so the app you were in stays @@ -29,6 +30,31 @@ final class CommandPanel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } + // MARK: Quick Look + + /// The file Quick Look shows: the selected result's. + var previewURL: (() -> URL?)? + /// Keys pressed while Quick Look has focus; arrows still move the selection, as in Finder. + var onPreviewKey: ((NSEvent) -> Bool)? + + override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel!) -> Bool { true } + + override func beginPreviewPanelControl(_ panel: QLPreviewPanel!) { + MainActor.assumeIsolated { + panel.dataSource = self + panel.delegate = self + } + } + + override func endPreviewPanelControl(_ panel: QLPreviewPanel!) { + MainActor.assumeIsolated { + panel.dataSource = nil + panel.delegate = nil + // Escape in Quick Look closes only the preview; typing carries on in the field. + if isVisible { makeKey() } + } + } + override func performKeyEquivalent(with event: NSEvent) -> Bool { onKeyEquivalent?(event) == true || super.performKeyEquivalent(with: event) } @@ -40,6 +66,22 @@ final class CommandPanel: NSPanel { } } +// Quick Look calls these on the main thread; its protocols just predate saying so. +extension CommandPanel: @preconcurrency QLPreviewPanelDataSource, @preconcurrency QLPreviewPanelDelegate { + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { + previewURL?() == nil ? 0 : 1 + } + + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + previewURL?() as NSURL? + } + + func previewPanel(_ panel: QLPreviewPanel!, handle event: NSEvent!) -> Bool { + guard let event, event.type == .keyDown else { return false } + return onPreviewKey?(event) ?? false + } +} + @MainActor final class PanelController: NSObject, NSWindowDelegate { let model: SearchModel @@ -64,6 +106,13 @@ 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 } + panel.previewURL = { [weak self] in self?.model.selected?.fileURL } + panel.onPreviewKey = { [weak self] event in + guard let self, event.keyCode == 125 || event.keyCode == 126 else { return false } + model.move(event.keyCode == 125 ? 1 : -1) + QLPreviewPanel.shared().reloadData() + return true + } model.onResultsChange = { [weak self] in self?.resize() } library.onChange = { [weak self] in guard let self, panel.isVisible else { return } @@ -78,6 +127,8 @@ final class PanelController: NSObject, NSWindowDelegate { } func show() { + // After a preview the app hid itself to hand focus back; its windows can't show until it unhides. + if NSApp.isHidden { NSApp.unhideWithoutActivation() } library.refreshIfStale() buildContentIfNeeded() panel.appearance = preferences.theme.appearance @@ -105,11 +156,40 @@ final class PanelController: NSObject, NSWindowDelegate { } func hide() { + if previewing { QLPreviewPanel.shared().orderOut(nil) } panel.orderOut(nil) + // Quick Look's panel activates the app, which the launcher otherwise never does. Hiding hands focus + // back to the app you were in, unless a window of ours, such as Settings, is still open. + if previewedSinceShown { + previewedSinceShown = false + let othersOpen = NSApp.windows.contains { $0.isVisible && $0 !== panel && !($0 is QLPreviewPanel) } + if NSApp.isActive && !othersOpen { NSApp.hide(nil) } + } + } + + private var previewedSinceShown = false + + /// Quick Look takes focus from the panel while it shows; that's the one time losing focus doesn't close. + private var previewing: Bool { + QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible } func windowDidResignKey(_ notification: Notification) { - hide() + // Checked a moment later: Quick Look takes focus before it reports itself visible. + DispatchQueue.main.async { [weak self] in + guard let self, !panel.isKeyWindow, !previewing else { return } + hide() + } + } + + private func toggleQuickLook() { + if previewing { + QLPreviewPanel.shared().orderOut(nil) + panel.makeKey() + } else if model.selected?.fileURL != nil { + previewedSinceShown = true + QLPreviewPanel.shared().makeKeyAndOrderFront(nil) + } } func preferencesChanged() { @@ -129,9 +209,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 @@ -187,7 +344,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 } 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/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) + } +} From 01b617494ff8979ead5f098c4f27481b8559558a Mon Sep 17 00:00:00 2001 From: Oddur Sigurdsson Date: Sat, 17 Oct 2026 10:05:57 -0400 Subject: [PATCH 2/2] fix(panel): show Quick Look beside the panel without taking focus A live test showed the system Quick Look panel couldn't get the keyboard: the launcher never activates the app, so the preview opened while the previous app kept focus, the launcher closed itself, and later keystrokes went to that app. QLPreviewView in a non-activating panel that can't become key keeps the keyboard in the search field, so arrows move the selection with the preview following and no app activation changes at all. --- README.md | 2 +- Sources/CommonCommand/PanelController.swift | 116 +++++++------------- Sources/CommonCommand/PreviewPanel.swift | 57 ++++++++++ Sources/CommonCommand/SearchModel.swift | 6 +- 4 files changed, 100 insertions(+), 81 deletions(-) create mode 100644 Sources/CommonCommand/PreviewPanel.swift diff --git a/README.md b/README.md index 763c14a..60d9938 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Spotlight owns ⌘Space on a new Mac, and launchers such as Alfred often take it | ↩ | 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; ↑ ↓ keep moving through the results | +| ⌘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 | diff --git a/Sources/CommonCommand/PanelController.swift b/Sources/CommonCommand/PanelController.swift index c475d3c..1aeb50b 100644 --- a/Sources/CommonCommand/PanelController.swift +++ b/Sources/CommonCommand/PanelController.swift @@ -1,6 +1,5 @@ import AppKit import CommandKit -import QuickLookUI import SwiftUI /// A borderless panel that takes typing without activating the app, so the app you were in stays @@ -30,31 +29,6 @@ final class CommandPanel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } - // MARK: Quick Look - - /// The file Quick Look shows: the selected result's. - var previewURL: (() -> URL?)? - /// Keys pressed while Quick Look has focus; arrows still move the selection, as in Finder. - var onPreviewKey: ((NSEvent) -> Bool)? - - override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel!) -> Bool { true } - - override func beginPreviewPanelControl(_ panel: QLPreviewPanel!) { - MainActor.assumeIsolated { - panel.dataSource = self - panel.delegate = self - } - } - - override func endPreviewPanelControl(_ panel: QLPreviewPanel!) { - MainActor.assumeIsolated { - panel.dataSource = nil - panel.delegate = nil - // Escape in Quick Look closes only the preview; typing carries on in the field. - if isVisible { makeKey() } - } - } - override func performKeyEquivalent(with event: NSEvent) -> Bool { onKeyEquivalent?(event) == true || super.performKeyEquivalent(with: event) } @@ -66,22 +40,6 @@ final class CommandPanel: NSPanel { } } -// Quick Look calls these on the main thread; its protocols just predate saying so. -extension CommandPanel: @preconcurrency QLPreviewPanelDataSource, @preconcurrency QLPreviewPanelDelegate { - func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { - previewURL?() == nil ? 0 : 1 - } - - func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { - previewURL?() as NSURL? - } - - func previewPanel(_ panel: QLPreviewPanel!, handle event: NSEvent!) -> Bool { - guard let event, event.type == .keyDown else { return false } - return onPreviewKey?(event) ?? false - } -} - @MainActor final class PanelController: NSObject, NSWindowDelegate { let model: SearchModel @@ -106,13 +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 } - panel.previewURL = { [weak self] in self?.model.selected?.fileURL } - panel.onPreviewKey = { [weak self] event in - guard let self, event.keyCode == 125 || event.keyCode == 126 else { return false } - model.move(event.keyCode == 125 ? 1 : -1) - QLPreviewPanel.shared().reloadData() - return 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 } @@ -127,8 +79,6 @@ final class PanelController: NSObject, NSWindowDelegate { } func show() { - // After a preview the app hid itself to hand focus back; its windows can't show until it unhides. - if NSApp.isHidden { NSApp.unhideWithoutActivation() } library.refreshIfStale() buildContentIfNeeded() panel.appearance = preferences.theme.appearance @@ -156,42 +106,46 @@ final class PanelController: NSObject, NSWindowDelegate { } func hide() { - if previewing { QLPreviewPanel.shared().orderOut(nil) } + closePreview() panel.orderOut(nil) - // Quick Look's panel activates the app, which the launcher otherwise never does. Hiding hands focus - // back to the app you were in, unless a window of ours, such as Settings, is still open. - if previewedSinceShown { - previewedSinceShown = false - let othersOpen = NSApp.windows.contains { $0.isVisible && $0 !== panel && !($0 is QLPreviewPanel) } - if NSApp.isActive && !othersOpen { NSApp.hide(nil) } - } - } - - private var previewedSinceShown = false - - /// Quick Look takes focus from the panel while it shows; that's the one time losing focus doesn't close. - private var previewing: Bool { - QLPreviewPanel.sharedPreviewPanelExists() && QLPreviewPanel.shared().isVisible } func windowDidResignKey(_ notification: Notification) { - // Checked a moment later: Quick Look takes focus before it reports itself visible. - DispatchQueue.main.async { [weak self] in - guard let self, !panel.isKeyWindow, !previewing else { return } - hide() - } + 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 previewing { - QLPreviewPanel.shared().orderOut(nil) - panel.makeKey() - } else if model.selected?.fileURL != nil { - previewedSinceShown = true - QLPreviewPanel.shared().makeKeyAndOrderFront(nil) + 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() @@ -310,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() @@ -439,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/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] = []