diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f78c11..5d7988b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable changes to this project are documented here. The format follows [Kee - System Settings topics and keywords from System Settings' own search terms, so "dark mode", "low power", or "firewall" find the right pane, shown with the pane's icon. - A result chosen for a query also leans shorter and longer starts of that query its way, so you can type less next time. - Unit conversions such as "10 km to mi", "70 f in c", or "5 GB in MB", offline, with Return to copy. +- Moving the pointer over a result selects it, and apps, files, and folders can be dragged out of the panel. ### Fixed diff --git a/README.md b/README.md index a733352..daa3b9e 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ Spotlight owns ⌘Space. To give it to Common Command, turn off **Show Spotlight | ⌘1 … ⌘9 | Open a result directly; hold ⌘ to see the numbers | | ⎋ | 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. + What a query finds, in order: 1. **Links.** `github.com/apple`, `https://…`, and `localhost:3000` open in your browser. Only well-known endings count, so `notes.md` and `Info.plist` stay file searches. diff --git a/Sources/CommonCommand/PanelController.swift b/Sources/CommonCommand/PanelController.swift index 959786e..1206a15 100644 --- a/Sources/CommonCommand/PanelController.swift +++ b/Sources/CommonCommand/PanelController.swift @@ -7,6 +7,7 @@ import SwiftUI final class CommandPanel: NSPanel { var onKeyEquivalent: ((NSEvent) -> Bool)? var onModifiersChange: ((NSEvent.ModifierFlags) -> Void)? + var onPointerMove: (() -> Void)? init() { super.init( @@ -20,6 +21,7 @@ final class CommandPanel: NSPanel { backgroundColor = .clear hasShadow = true animationBehavior = .none + acceptsMouseMovedEvents = true isReleasedWhenClosed = false // On the current Space, including over full-screen apps, and never in ⌘` or Mission Control. collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] @@ -33,7 +35,12 @@ final class CommandPanel: NSPanel { } override func sendEvent(_ event: NSEvent) { - if event.type == .flagsChanged { onModifiersChange?(event.modifierFlags) } + 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 + } super.sendEvent(event) } } @@ -61,6 +68,7 @@ final class PanelController: NSObject, NSWindowDelegate { 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 guard let self, panel.isVisible else { return } @@ -80,6 +88,7 @@ final class PanelController: NSObject, NSWindowDelegate { buildContentIfNeeded() panel.appearance = preferences.theme.appearance model.showsShortcuts = false + model.pointerMoved = false model.update() position() panel.makeKeyAndOrderFront(nil) diff --git a/Sources/CommonCommand/PanelView.swift b/Sources/CommonCommand/PanelView.swift index e1127da..a1d9545 100644 --- a/Sources/CommonCommand/PanelView.swift +++ b/Sources/CommonCommand/PanelView.swift @@ -60,6 +60,10 @@ private struct ResultList: View { ) .id(index) .onTapGesture { choose(index) } + .onContinuousHover { phase in + if case .active = phase { model.hover(index) } + } + .dragsFile(ranked.item.fileURL) } } .padding(size.listPadding) @@ -72,6 +76,17 @@ private struct ResultList: View { } } +extension View { + /// Apps, files, and folders drag out like they do from Finder: into the Dock, a folder, or a document. + @ViewBuilder func dragsFile(_ url: URL?) -> some View { + if let url { + onDrag { NSItemProvider(object: url as NSURL) } + } else { + self + } + } +} + private struct ResultRow: View { let ranked: Ranked let size: PanelSize diff --git a/Sources/CommonCommand/SearchModel.swift b/Sources/CommonCommand/SearchModel.swift index f55bbdd..b0863f2 100644 --- a/Sources/CommonCommand/SearchModel.swift +++ b/Sources/CommonCommand/SearchModel.swift @@ -7,6 +7,7 @@ final class SearchModel { var query = "" { didSet { guard query != oldValue else { return } + pointerMoved = false update() } } @@ -14,6 +15,9 @@ final class SearchModel { var selection = 0 /// True while ⌘ is held, so each row shows its ⌘1–⌘9 shortcut. var showsShortcuts = false + /// 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 @ObservationIgnored var onResultsChange: (() -> Void)? @ObservationIgnored private let library: Library @@ -32,10 +36,17 @@ final class SearchModel { var selected: Item? { results.indices.contains(selection) ? results[selection].item : nil } func move(_ delta: Int) { + pointerMoved = false guard !results.isEmpty else { return } selection = (selection + delta + results.count) % results.count } + /// The pointer is over row `index`; it takes the selection only if the pointer got there by moving. + func hover(_ index: Int) { + guard pointerMoved, results.indices.contains(index), selection != index else { return } + selection = index + } + /// Recomputes from what's already known, then asks Spotlight for files. A new query selects the /// top result. Results that arrive later, such as files or a refreshed app list, keep a selection /// you moved, but otherwise the top result stays selected even if it changed.