Skip to content
This repository was archived by the owner on Oct 17, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
62 changes: 62 additions & 0 deletions Sources/CommandKit/ItemCommands.swift
Original file line number Diff line number Diff line change
@@ -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]
}
}
}
24 changes: 24 additions & 0 deletions Sources/CommonCommand/ActionMenuItem.swift
Original file line number Diff line number Diff line change
@@ -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() }
}
126 changes: 122 additions & 4 deletions Sources/CommonCommand/PanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -105,13 +106,46 @@ final class PanelController: NSObject, NSWindowDelegate {
}

func hide() {
closePreview()
panel.orderOut(nil)
}

func windowDidResignKey(_ notification: Notification) {
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()
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Sources/CommonCommand/PanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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) }
}
}
}
}
}
Expand Down
57 changes: 57 additions & 0 deletions Sources/CommonCommand/PreviewPanel.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
6 changes: 5 additions & 1 deletion Sources/CommonCommand/SearchModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand 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] = []
Expand Down
Loading
Loading