1444 lines
66 KiB
Swift
1444 lines
66 KiB
Swift
import AppKit
|
||
import ConjureKit
|
||
import CoreText
|
||
import ApplicationServices
|
||
|
||
@MainActor
|
||
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
||
private let searchField = NSSearchField()
|
||
private let searchSymbol = IconView(font: .systemFont(ofSize: 20))
|
||
private let commandBack = NSButton(title: "", target: nil, action: nil)
|
||
private let translationHeader = NSStackView()
|
||
private let tableView = ApplicationTableView()
|
||
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
|
||
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
|
||
private let sectionLabel = NSTextField(labelWithString: "Applications")
|
||
private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
|
||
private let history: LaunchHistory
|
||
private let defaults: UserDefaults
|
||
private let pasteboard: NSPasteboard
|
||
private var applications: [Application] = Application.commands
|
||
private var results: [Application] = []
|
||
private var calculation: CalculatorResult?
|
||
private var isCurrencyCalculation = false
|
||
private let currency = CurrencyCalculation()
|
||
private var locationLabels: [URL: String] = [:]
|
||
private var discoveryTask: Task<Void, Never>?
|
||
private var isLaunching = false
|
||
private var previousApplication: NSRunningApplication?
|
||
private var alignmentGuides: LauncherAlignmentGuides?
|
||
private static let positionKey = "launcherPosition"
|
||
private static let notesSizeKey = "notesWindowSize"
|
||
private let uninstaller: ApplicationUninstaller
|
||
private let actionsButton = NSButton(title: "Actions ⌘K", target: nil, action: nil)
|
||
private let actions = ApplicationActionsController()
|
||
private var uninstallReview: UninstallReviewController?
|
||
private var launcherViews: [NSView] = []
|
||
private var launcherFrameBeforeReview: NSRect?
|
||
private var translation: TranslationController?
|
||
private var emojiPicker: EmojiPickerController?
|
||
private var notes: NotesController?
|
||
private let notesDirectory: URL?
|
||
private let toggleSystemTheme: @MainActor () async throws -> Void
|
||
private var isCommandOpen: Bool { activeCommand != nil }
|
||
private var activeCommand: Application.Command?
|
||
private var pendingPresentation: (() -> Void)?
|
||
private static let emojiIcon = makeEmojiIcon("🤔")
|
||
private static let notesIcon = makeEmojiIcon("📔")
|
||
private static let translateIcon = makeEmojiIcon("🌏")
|
||
private static let themeIcon = makeEmojiIcon("🌗")
|
||
|
||
private static func makeEmojiIcon(_ emoji: String) -> NSImage {
|
||
let text = NSAttributedString(string: emoji, attributes: [.font: NSFont.systemFont(ofSize: 28)])
|
||
let line = CTLineCreateWithAttributedString(text)
|
||
return NSImage(size: NSSize(width: 32, height: 32), flipped: false) { rect in
|
||
guard let context = NSGraphicsContext.current?.cgContext else { return false }
|
||
context.textPosition = .zero
|
||
let bounds = CTLineGetImageBounds(line, context)
|
||
context.textPosition = CGPoint(x: rect.midX - bounds.midX, y: rect.midY - bounds.midY)
|
||
CTLineDraw(line, context)
|
||
return true
|
||
}
|
||
}
|
||
|
||
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller(),
|
||
defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general, notesDirectory: URL? = nil,
|
||
toggleSystemTheme: @escaping @MainActor () async throws -> Void = { try await SystemTheme.toggle() }) {
|
||
self.history = history
|
||
self.uninstaller = uninstaller
|
||
self.defaults = defaults
|
||
self.pasteboard = pasteboard
|
||
self.notesDirectory = notesDirectory
|
||
self.toggleSystemTheme = toggleSystemTheme
|
||
let window = LauncherWindow(
|
||
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||
styleMask: [.borderless],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
window.title = "Conjure"
|
||
window.titleVisibility = .hidden
|
||
window.titlebarAppearsTransparent = true
|
||
window.animationBehavior = .none
|
||
window.appearance = NSAppearance(named: .darkAqua)
|
||
window.backgroundColor = .clear
|
||
window.isOpaque = false
|
||
window.hasShadow = true
|
||
window.isMovableByWindowBackground = true
|
||
window.hidesOnDeactivate = false
|
||
window.collectionBehavior = [.moveToActiveSpace, .fullScreenAuxiliary]
|
||
window.isReleasedWhenClosed = false
|
||
window.center()
|
||
super.init(window: window)
|
||
configureContent(in: window)
|
||
window.delegate = self
|
||
window.navigateSelection = { [weak self] offset in
|
||
guard let self else { return }
|
||
if let review = self.uninstallReview { review.moveSelection(offset) }
|
||
else if self.actions.isShown { self.actions.moveSelection(by: offset) }
|
||
else if let picker = self.emojiPicker { picker.moveSelection(by: offset) }
|
||
else { self.moveSelection(by: offset) }
|
||
}
|
||
window.commandNavigation = { [weak self] key in
|
||
self?.notes?.handleControlKey(key) ?? self?.translation?.handleControlKey(key)
|
||
?? self?.emojiPicker?.handleControlKey(key) ?? false
|
||
}
|
||
window.commandShortcut = { [weak self] key in self?.notes?.handleCommandKey(key) ?? false }
|
||
window.usesTextNavigation = { [weak self] in self?.notes != nil }
|
||
window.dismiss = { [weak self] in self?.dismiss() }
|
||
window.escape = { [weak self] in self?.handleEscape() }
|
||
window.canUseNumberShortcuts = { [weak self] in
|
||
guard let self else { return false }
|
||
return !self.isLaunching && self.calculation == nil && self.resultCount > 0
|
||
&& self.launcherFrameBeforeReview == nil && !self.actions.isShown && !self.isCommandOpen
|
||
}
|
||
window.numberHintsChanged = { [weak self] in self?.updateNumberHints() }
|
||
window.launchNumber = { [weak self] index in
|
||
guard let self else { return }
|
||
if self.results.indices.contains(index) { self.openApplication(self.results[index]) }
|
||
}
|
||
window.dragEnded = { [weak self] in
|
||
guard let self else { return }
|
||
self.alignmentGuides?.end(snapping: self.window)
|
||
self.savePosition()
|
||
}
|
||
actions.closed = { [weak self] in
|
||
guard let self else { return }
|
||
self.openButton.keyEquivalent = "\r"
|
||
self.window?.makeFirstResponder(self.searchField)
|
||
}
|
||
currency.changed = { [weak self] in self?.updateResults(preservingSelection: true) }
|
||
restorePosition()
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("Conjure creates its window programmatically")
|
||
}
|
||
|
||
func present(command: Application.Command? = nil) {
|
||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||
if let command, !command.presentsView {
|
||
guard launcherFrameBeforeReview == nil else { return }
|
||
executeCommand(command)
|
||
return
|
||
}
|
||
if launcherFrameBeforeReview != nil {
|
||
pendingPresentation = { [weak self] in self?.present(command: command) }
|
||
uninstallReview?.goBack()
|
||
return
|
||
}
|
||
guard closeCommand() else { return }
|
||
actions.close(animated: false)
|
||
(window as? LauncherWindow)?.beginNumberHintSession()
|
||
let frontmost = NSWorkspace.shared.frontmostApplication
|
||
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
|
||
previousApplication = frontmost
|
||
}
|
||
searchField.stringValue = ""
|
||
restorePosition()
|
||
updateResults()
|
||
if let command {
|
||
executeCommand(command)
|
||
// Validate the final size; compact Notes may fit where the launcher did not.
|
||
if command == .notes { restorePosition() }
|
||
}
|
||
window?.deminiaturize(nil)
|
||
showWindow(nil)
|
||
NSApp.activate()
|
||
window?.makeKeyAndOrderFront(nil)
|
||
if let notes { notes.focusEditor() }
|
||
else { window?.makeFirstResponder(searchField) }
|
||
(window as? LauncherWindow)?.sampleNumberHints()
|
||
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||
refreshApplications()
|
||
}
|
||
|
||
func toggle(command: Application.Command? = nil) {
|
||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||
if window?.isVisible == true, window?.isKeyWindow == true,
|
||
launcherFrameBeforeReview == nil, activeCommand == command {
|
||
dismiss()
|
||
} else {
|
||
present(command: command)
|
||
}
|
||
}
|
||
|
||
func dismiss(restoringFocus: Bool = true) {
|
||
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
|
||
if launcherFrameBeforeReview != nil {
|
||
guard uninstallReview?.canClose != false else { return }
|
||
pendingPresentation = { [weak self] in self?.dismiss(restoringFocus: restoringFocus) }
|
||
uninstallReview?.goBack()
|
||
return
|
||
}
|
||
guard closeCommand() else { return }
|
||
actions.close(animated: false)
|
||
alignmentGuides?.end()
|
||
let previous = previousApplication
|
||
previousApplication = nil
|
||
window?.orderOut(nil)
|
||
if restoringFocus, let previous, !previous.isTerminated {
|
||
NSApp.yieldActivation(to: previous)
|
||
previous.activate(from: .current, options: [])
|
||
}
|
||
}
|
||
|
||
private func savePosition() {
|
||
guard launcherFrameBeforeReview == nil, let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return }
|
||
defaults.set([
|
||
"display": identifier, "centerX": window.frame.midX, "centerY": window.frame.midY
|
||
], forKey: Self.positionKey)
|
||
}
|
||
|
||
private func restorePosition() {
|
||
guard let window else { return }
|
||
let saved = defaults.dictionary(forKey: Self.positionKey)
|
||
let savedScreen = NSScreen.screens.first { displayIdentifier($0) == saved?["display"] as? String }
|
||
guard let screen = savedScreen ?? NSScreen.main ?? NSScreen.screens.first else { return }
|
||
var center: CGPoint?
|
||
if savedScreen != nil, let x = saved?["centerX"] as? Double, let y = saved?["centerY"] as? Double {
|
||
center = CGPoint(x: x, y: y)
|
||
}
|
||
let frame = LauncherAlignment.restoredFrame(
|
||
savedCenter: center, windowSize: window.frame.size, visibleFrame: screen.visibleFrame
|
||
)
|
||
if window.frame.origin != frame.origin { window.setFrameOrigin(frame.origin) }
|
||
}
|
||
|
||
private func displayIdentifier(_ screen: NSScreen) -> String? {
|
||
guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber,
|
||
let uuid = CGDisplayCreateUUIDFromDisplayID(number.uint32Value)?.takeRetainedValue() else { return nil }
|
||
return CFUUIDCreateString(nil, uuid) as String
|
||
}
|
||
|
||
func windowWillMove(_ notification: Notification) {
|
||
guard let window else { return }
|
||
let guides = alignmentGuides ?? LauncherAlignmentGuides()
|
||
alignmentGuides = guides
|
||
guides.begin(for: window)
|
||
}
|
||
|
||
func windowDidMove(_ notification: Notification) {
|
||
guard let window else { return }
|
||
alignmentGuides?.update(for: window)
|
||
}
|
||
|
||
func windowDidResize(_ notification: Notification) {
|
||
guard notes != nil, let window, window.styleMask.contains(.resizable) else { return }
|
||
defaults.set(NSStringFromSize(window.frame.size), forKey: Self.notesSizeKey)
|
||
}
|
||
|
||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||
dismiss()
|
||
return false
|
||
}
|
||
|
||
func windowDidResignKey(_ notification: Notification) {
|
||
(window as? LauncherWindow)?.resetNumberHints()
|
||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||
dismiss(restoringFocus: false)
|
||
}
|
||
|
||
private func configureContent(in window: NSWindow) {
|
||
let content = NSVisualEffectView()
|
||
content.material = .hudWindow
|
||
content.blendingMode = .behindWindow
|
||
content.state = .active
|
||
roundMaterialCorners(content, radius: 24)
|
||
content.layer?.borderWidth = 0.5
|
||
window.contentView = content
|
||
let background = LauncherBackground(frame: content.bounds)
|
||
background.autoresizingMask = [.width, .height]
|
||
background.setAccessibilityElement(false)
|
||
content.addSubview(background)
|
||
|
||
let searchFont = NSFont.systemFont(ofSize: 20)
|
||
searchField.font = searchFont
|
||
searchField.placeholderAttributedString = NSAttributedString(
|
||
string: "Search apps, calculate, or convert…",
|
||
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
|
||
)
|
||
searchField.textColor = SunsetPalette.text
|
||
searchField.isBordered = false
|
||
searchField.drawsBackground = false
|
||
searchField.focusRingType = .none
|
||
if let cell = searchField.cell as? NSSearchFieldCell {
|
||
cell.searchButtonCell = nil
|
||
cell.cancelButtonCell = nil
|
||
}
|
||
searchField.sendsSearchStringImmediately = true
|
||
searchField.sendsWholeSearchString = false
|
||
searchField.delegate = self
|
||
searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
|
||
|
||
searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
|
||
searchSymbol.contentTintColor = SunsetPalette.accent
|
||
commandBack.image = IconStyle.symbol("chevron.left", for: searchFont)
|
||
commandBack.imageScaling = .scaleProportionallyDown
|
||
commandBack.isBordered = false
|
||
commandBack.contentTintColor = SunsetPalette.secondaryText
|
||
commandBack.setAccessibilityLabel("Back to applications")
|
||
commandBack.toolTip = "Back to applications"
|
||
commandBack.isHidden = true
|
||
NSLayoutConstraint.activate([
|
||
commandBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
||
commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
||
])
|
||
translationHeader.isHidden = true
|
||
let header = NSStackView(views: [searchSymbol, commandBack, searchField, translationHeader])
|
||
header.orientation = .horizontal
|
||
header.alignment = .centerY
|
||
header.spacing = 12
|
||
|
||
sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold)
|
||
sectionLabel.textColor = SunsetPalette.secondaryText
|
||
|
||
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
|
||
column.resizingMask = .autoresizingMask
|
||
tableView.addTableColumn(column)
|
||
tableView.headerView = nil
|
||
tableView.style = .plain
|
||
tableView.backgroundColor = .clear
|
||
tableView.rowHeight = LauncherRowView.rowHeight
|
||
tableView.intercellSpacing = .zero
|
||
tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
|
||
tableView.allowsMultipleSelection = false
|
||
tableView.allowsEmptySelection = false
|
||
tableView.dataSource = self
|
||
tableView.delegate = self
|
||
tableView.target = self
|
||
tableView.doubleAction = #selector(openSelection)
|
||
tableView.openSelection = { [weak self] in self?.openSelection() }
|
||
tableView.dismiss = { [weak self] in self?.dismiss() }
|
||
tableView.editSearch = { [weak self] event in
|
||
guard let self, let window = self.window else { return }
|
||
window.makeFirstResponder(self.searchField)
|
||
self.searchField.currentEditor()?.keyDown(with: event)
|
||
}
|
||
tableView.setAccessibilityLabel("Applications")
|
||
|
||
let scrollView = LauncherScrollView()
|
||
scrollView.documentView = tableView
|
||
scrollView.hasVerticalScroller = true
|
||
scrollView.autohidesScrollers = true
|
||
scrollView.scrollerStyle = .overlay
|
||
scrollView.borderType = .noBorder
|
||
scrollView.drawsBackground = false
|
||
scrollView.contentView.drawsBackground = false
|
||
scrollView.horizontalScrollElasticity = .none
|
||
scrollView.automaticallyAdjustsContentInsets = false
|
||
scrollView.contentInsets = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 0, bottom: 16, right: 0)
|
||
|
||
statusLabel.font = .systemFont(ofSize: 12)
|
||
statusLabel.textColor = SunsetPalette.secondaryText
|
||
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||
emptyLabel.textColor = SunsetPalette.secondaryText
|
||
emptyLabel.alignment = .center
|
||
|
||
openButton.target = self
|
||
openButton.action = #selector(openSelection)
|
||
openButton.isEnabled = false
|
||
actionsButton.target = self
|
||
actionsButton.action = #selector(showActions)
|
||
actionsButton.setAccessibilityLabel("Entry actions")
|
||
actionsButton.toolTip = "Actions for the selected application or command (Command-K)"
|
||
actionsButton.isEnabled = false
|
||
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
|
||
let actionPill = ActionPill(primaryButton: openButton, actionsButton: actionsButton)
|
||
|
||
let footer = NSStackView(views: [statusLabel, NSView(), actionPill])
|
||
footer.orientation = .horizontal
|
||
footer.alignment = .centerY
|
||
footer.spacing = 12
|
||
for view in [scrollView, header, sectionLabel, emptyLabel, footer] {
|
||
view.translatesAutoresizingMaskIntoConstraints = false
|
||
content.addSubview(view)
|
||
}
|
||
NSLayoutConstraint.activate([
|
||
header.topAnchor.constraint(equalTo: content.topAnchor, constant: 20),
|
||
header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
||
header.trailingAnchor.constraint(equalTo: footer.trailingAnchor),
|
||
header.heightAnchor.constraint(equalToConstant: 36),
|
||
sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16),
|
||
sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
||
scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: -10),
|
||
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
|
||
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
|
||
scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: 8),
|
||
emptyLabel.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
|
||
emptyLabel.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor),
|
||
emptyLabel.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 16),
|
||
emptyLabel.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -16),
|
||
footer.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
||
footer.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
|
||
footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
|
||
footer.heightAnchor.constraint(equalToConstant: 36),
|
||
])
|
||
window.effectiveAppearance.performAsCurrentDrawingAppearance {
|
||
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||
content.layer?.borderColor = border
|
||
}
|
||
window.initialFirstResponder = searchField
|
||
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
|
||
}
|
||
|
||
private func refreshApplications() {
|
||
guard discoveryTask == nil else { return }
|
||
statusLabel.stringValue = "Finding applications…"
|
||
discoveryTask = Task { [weak self] in
|
||
let applications = await Task.detached(priority: .userInitiated) {
|
||
ApplicationCatalog.discover()
|
||
}.value
|
||
guard let self else { return }
|
||
guard !Task.isCancelled else { return }
|
||
self.applications = applications + Application.commands
|
||
self.updateLocationLabels()
|
||
self.discoveryTask = nil
|
||
self.updateResults(preservingSelection: true)
|
||
}
|
||
}
|
||
|
||
private func updateLocationLabels() {
|
||
var nameCounts: [String: Int] = [:]
|
||
nameCounts.reserveCapacity(applications.count)
|
||
for application in applications where application.command == nil { nameCounts[application.name, default: 0] += 1 }
|
||
var groups: [String: [(url: URL, folder: URL)]] = [:]
|
||
for application in applications where application.command == nil && (nameCounts[application.name] ?? 0) > 1 {
|
||
groups[application.name, default: []].append(
|
||
(application.url, application.url.deletingLastPathComponent())
|
||
)
|
||
}
|
||
locationLabels.removeAll(keepingCapacity: true)
|
||
for group in groups.values {
|
||
var folderCounts: [URL: Int] = [:]
|
||
folderCounts.reserveCapacity(group.count)
|
||
for entry in group { folderCounts[entry.folder, default: 0] += 1 }
|
||
for entry in group {
|
||
let location = (folderCounts[entry.folder] ?? 0) > 1 ? entry.url : entry.folder
|
||
locationLabels[entry.url] = (location.path as NSString).abbreviatingWithTildeInPath
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateResults(preservingSelection: Bool = false) {
|
||
guard !isCommandOpen else { return }
|
||
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
||
? results[tableView.selectedRow].id : nil
|
||
let query = searchField.stringValue
|
||
currency.update(query)
|
||
isCurrencyCalculation = currency.isCurrency
|
||
calculation = currency.result
|
||
if calculation != nil { (window as? LauncherWindow)?.resetNumberHints() }
|
||
if calculation != nil {
|
||
results = []
|
||
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
results = history.suggestions(in: applications)
|
||
} else {
|
||
results = history.results(for: searchField.stringValue, in: applications)
|
||
}
|
||
sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates"
|
||
: calculation == nil ? "Applications & Commands" : "Calculator"
|
||
tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result"
|
||
: calculation == nil ? "Applications and commands" : "Calculator result")
|
||
tableView.reloadData()
|
||
if resultCount > 0 {
|
||
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
|
||
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||
tableView.scrollRowToVisible(row)
|
||
}
|
||
emptyLabel.isHidden = resultCount > 0
|
||
if let calculation {
|
||
statusLabel.stringValue = calculation.detail
|
||
} else if discoveryTask != nil {
|
||
emptyLabel.stringValue = "Finding applications…"
|
||
statusLabel.stringValue = "Finding applications…"
|
||
} else {
|
||
emptyLabel.stringValue = "No matching applications or commands"
|
||
let commandCount = results.lazy.filter { $0.command != nil }.count
|
||
statusLabel.stringValue = "\(results.count - commandCount) applications · \(commandCount) commands"
|
||
}
|
||
updateOpenButton()
|
||
(window as? LauncherWindow)?.sampleNumberHints()
|
||
}
|
||
|
||
func controlTextDidChange(_ notification: Notification) {
|
||
if let translation { translation.inputChanged(); return }
|
||
if let emojiPicker { emojiPicker.inputChanged(); return }
|
||
updateResults()
|
||
}
|
||
|
||
private func handleEscape() {
|
||
let selector = #selector(NSResponder.cancelOperation(_:))
|
||
if actions.handle(selector) || notes?.handle(selector) == true
|
||
|| translation?.handle(selector) == true || emojiPicker?.handle(selector) == true { return }
|
||
dismiss()
|
||
}
|
||
|
||
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
|
||
handleEscape()
|
||
return true
|
||
}
|
||
if let notes { return notes.handle(commandSelector) }
|
||
if let emojiPicker { return emojiPicker.handle(commandSelector) }
|
||
if let translation { return translation.handle(commandSelector) }
|
||
switch commandSelector {
|
||
case #selector(NSResponder.moveDown(_:)):
|
||
moveSelection(by: 1)
|
||
case #selector(NSResponder.moveUp(_:)):
|
||
moveSelection(by: -1)
|
||
case #selector(NSResponder.insertNewline(_:)):
|
||
openSelection()
|
||
default:
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
private var resultCount: Int { calculation == nil ? results.count : 1 }
|
||
|
||
private func moveSelection(by offset: Int) {
|
||
guard !isCommandOpen else { return }
|
||
guard resultCount > 0 else { return }
|
||
let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1)
|
||
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||
tableView.scrollRowToVisible(row)
|
||
}
|
||
|
||
private func updateOpenButton() {
|
||
let canAct = !isLaunching && launcherFrameBeforeReview == nil && !isCommandOpen && tableView.selectedRow >= 0
|
||
openButton.title = calculation == nil
|
||
? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil
|
||
? "Open Command" : "Open Application") : "Copy Answer"
|
||
openButton.toolTip = calculation == nil
|
||
? "Open the selected entry (Return). Use ↑ and ↓ to select."
|
||
: "Copy the answer to the clipboard (Return)."
|
||
openButton.isEnabled = canAct && (calculation.map { $0.copyValue != nil }
|
||
?? results.indices.contains(tableView.selectedRow))
|
||
actionsButton.isEnabled = canAct && calculation == nil && results.indices.contains(tableView.selectedRow)
|
||
}
|
||
|
||
func tableViewSelectionDidChange(_ notification: Notification) {
|
||
updateOpenButton()
|
||
}
|
||
|
||
func numberOfRows(in tableView: NSTableView) -> Int {
|
||
resultCount
|
||
}
|
||
|
||
private func spacingBeforeRow(_ row: Int) -> CGFloat {
|
||
guard calculation == nil,
|
||
searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||
row > 0, results.indices.contains(row),
|
||
history.isFavorite(results[row - 1]), !history.isFavorite(results[row]) else { return 0 }
|
||
return 12
|
||
}
|
||
|
||
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
|
||
LauncherRowView.rowHeight + spacingBeforeRow(row)
|
||
}
|
||
|
||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||
let identifier = NSUserInterfaceItemIdentifier("applicationCell")
|
||
let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? ApplicationCell)
|
||
?? makeCell(identifier: identifier)
|
||
if let calculation {
|
||
cell.textField?.stringValue = calculation.displayValue
|
||
cell.textField?.setAccessibilityLabel(nil)
|
||
cell.locationLabel.isHidden = true
|
||
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
|
||
: calculation.copyValue == nil ? "Error" : "Calculator"
|
||
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)
|
||
cell.imageView?.contentTintColor = SunsetPalette.accent
|
||
cell.toolTip = calculation.detail
|
||
} else if results[row].command != nil {
|
||
let entry = results[row]
|
||
cell.textField?.stringValue = entry.name
|
||
cell.textField?.setAccessibilityLabel(history.isFavorite(entry) ? "\(entry.name), Favorite" : nil)
|
||
cell.locationLabel.isHidden = true
|
||
cell.kindLabel.attributedStringValue = history.isFavorite(entry)
|
||
? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind
|
||
switch entry.command {
|
||
case .emoji:
|
||
cell.imageView?.image = Self.emojiIcon
|
||
cell.toolTip = "Search and copy emoji"
|
||
case .notes:
|
||
cell.imageView?.image = Self.notesIcon
|
||
cell.toolTip = "Write notes with Markdown and images"
|
||
case .toggleTheme:
|
||
cell.imageView?.image = Self.themeIcon
|
||
cell.toolTip = "Switch macOS between light and dark appearance"
|
||
default:
|
||
cell.imageView?.image = Self.translateIcon
|
||
cell.toolTip = "Translate English to Japanese"
|
||
}
|
||
cell.imageView?.contentTintColor = nil
|
||
} else {
|
||
let application = results[row]
|
||
cell.textField?.stringValue = application.name
|
||
let location = locationLabels[application.id]
|
||
cell.locationLabel.stringValue = location ?? ""
|
||
cell.locationLabel.isHidden = location == nil
|
||
let isFavorite = history.isFavorite(application)
|
||
cell.kindLabel.attributedStringValue = isFavorite
|
||
? ApplicationCell.favoriteKind : ApplicationCell.applicationKind
|
||
cell.textField?.setAccessibilityLabel(isFavorite ? "\(application.name), Favorite" : nil)
|
||
cell.imageView?.contentTintColor = nil
|
||
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
|
||
cell.toolTip = application.url.path
|
||
}
|
||
configureNumberHint(cell, row: row)
|
||
return cell
|
||
}
|
||
|
||
func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
|
||
let identifier = NSUserInterfaceItemIdentifier("applicationRow")
|
||
let view = (tableView.makeView(withIdentifier: identifier, owner: self) as? LauncherRowView)
|
||
?? LauncherRowView()
|
||
view.identifier = identifier
|
||
view.topSpacing = spacingBeforeRow(row)
|
||
return view
|
||
}
|
||
|
||
private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) {
|
||
let visible = (window as? LauncherWindow)?.numberHintsVisible == true
|
||
&& calculation == nil && row < 9 && row < resultCount && !isCommandOpen
|
||
cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated)
|
||
cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil)
|
||
}
|
||
|
||
private func updateNumberHints() {
|
||
let rows = tableView.rows(in: tableView.visibleRect)
|
||
guard rows.length > 0 else { return }
|
||
for row in rows.location..<(rows.location + rows.length) {
|
||
if let cell = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? ApplicationCell {
|
||
configureNumberHint(cell, row: row, animated: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell {
|
||
let cell = ApplicationCell()
|
||
cell.identifier = identifier
|
||
return cell
|
||
}
|
||
|
||
@objc private func showActions() {
|
||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
|
||
guard !isCommandOpen else { return }
|
||
(window as? LauncherWindow)?.resetNumberHints()
|
||
let application = results[tableView.selectedRow]
|
||
actions.open = { [weak self] in self?.openApplication(application) }
|
||
actions.isFavorite = history.isFavorite(application)
|
||
actions.toggleFavorite = { [weak self] in
|
||
guard let self else { return }
|
||
self.history.toggleFavorite(application)
|
||
self.updateResults(preservingSelection: true)
|
||
}
|
||
actions.showInFinder = { [weak self] in
|
||
self?.dismiss(restoringFocus: false)
|
||
NSWorkspace.shared.activateFileViewerSelecting([application.url])
|
||
}
|
||
actions.resetRanking = { [weak self] in
|
||
guard let self else { return }
|
||
self.history.resetRanking(of: application)
|
||
self.updateResults(preservingSelection: true)
|
||
}
|
||
actions.quit = { [weak self] in self?.quitApplication(application) }
|
||
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
|
||
actions.show(for: application, relativeTo: actionsButton)
|
||
openButton.keyEquivalent = actions.isShown ? "" : "\r"
|
||
}
|
||
|
||
private func reviewUninstall(_ application: Application) {
|
||
guard let window, let content = window.contentView, launcherFrameBeforeReview == nil, window.attachedSheet == nil else { return }
|
||
let review = UninstallReviewController(application: application, uninstaller: uninstaller)
|
||
uninstallReview = review
|
||
launcherFrameBeforeReview = window.frame
|
||
review.closed = { [weak self, weak review] in
|
||
guard let self, let review else { return }
|
||
self.restoreLauncher(from: review)
|
||
}
|
||
review.changed = { [weak self] in
|
||
guard let self else { return }
|
||
self.discoveryTask?.cancel()
|
||
self.discoveryTask = nil
|
||
self.refreshApplications()
|
||
}
|
||
review.view.translatesAutoresizingMaskIntoConstraints = false
|
||
review.view.alphaValue = 0
|
||
content.addSubview(review.view)
|
||
NSLayoutConstraint.activate([
|
||
review.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||
review.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||
review.view.topAnchor.constraint(equalTo: content.topAnchor),
|
||
review.view.bottomAnchor.constraint(equalTo: content.bottomAnchor)
|
||
])
|
||
for view in launcherViews { view.setAccessibilityHidden(true) }
|
||
(window as? LauncherWindow)?.resetNumberHints()
|
||
openButton.keyEquivalent = ""
|
||
updateOpenButton()
|
||
var frame = window.frame
|
||
frame.size.height = min(540, window.screen?.visibleFrame.height ?? 540)
|
||
frame.origin.y = window.frame.maxY - frame.height
|
||
if let screen = window.screen { frame.origin.y = max(frame.origin.y, screen.visibleFrame.minY) }
|
||
NSAnimationContext.runAnimationGroup { context in
|
||
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.18
|
||
window.animator().setFrame(frame, display: true)
|
||
for view in launcherViews { view.animator().alphaValue = 0 }
|
||
review.view.animator().alphaValue = 1
|
||
}
|
||
review.present()
|
||
}
|
||
|
||
private func restoreLauncher(from review: UninstallReviewController) {
|
||
guard let window, let frame = launcherFrameBeforeReview, uninstallReview === review else { return }
|
||
uninstallReview = nil
|
||
for view in launcherViews { view.setAccessibilityHidden(false) }
|
||
NSAnimationContext.runAnimationGroup { context in
|
||
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.18
|
||
window.animator().setFrame(frame, display: true)
|
||
for view in launcherViews { view.animator().alphaValue = 1 }
|
||
review.view.animator().alphaValue = 0
|
||
} completionHandler: { [weak self] in
|
||
MainActor.assumeIsolated {
|
||
review.view.removeFromSuperview()
|
||
guard let self else { return }
|
||
self.launcherFrameBeforeReview = nil
|
||
self.openButton.keyEquivalent = "\r"
|
||
self.updateOpenButton()
|
||
self.window?.makeFirstResponder(self.searchField)
|
||
let pending = self.pendingPresentation
|
||
self.pendingPresentation = nil
|
||
pending?()
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc private func openSelection() {
|
||
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||
if let calculation {
|
||
guard tableView.selectedRow == 0, let value = calculation.copyValue else { return }
|
||
pasteboard.clearContents()
|
||
if pasteboard.setString(value, forType: .string) {
|
||
dismiss()
|
||
}
|
||
return
|
||
}
|
||
guard results.indices.contains(tableView.selectedRow) else { return }
|
||
openApplication(results[tableView.selectedRow])
|
||
}
|
||
|
||
private func quitApplication(_ application: Application) {
|
||
do {
|
||
try AppLauncher.quit(application.url)
|
||
dismiss()
|
||
} catch {
|
||
let alert = NSAlert(error: error)
|
||
alert.messageText = "Couldn’t quit \(application.name)"
|
||
if let window { alert.beginSheetModal(for: window) }
|
||
}
|
||
}
|
||
|
||
private func openApplication(_ application: Application) {
|
||
guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||
if let command = application.command {
|
||
executeCommand(command, dismissOnSuccess: true)
|
||
return
|
||
}
|
||
(window as? LauncherWindow)?.resetNumberHints()
|
||
isLaunching = true
|
||
updateOpenButton()
|
||
Task {
|
||
do {
|
||
try await AppLauncher.launch(application.url)
|
||
history.recordLaunch(of: application)
|
||
dismiss(restoringFocus: false)
|
||
} catch {
|
||
let alert = NSAlert(error: error)
|
||
alert.messageText = "Couldn’t open \(application.name)"
|
||
if let window {
|
||
await alert.beginSheetModal(for: window)
|
||
}
|
||
}
|
||
isLaunching = false
|
||
updateOpenButton()
|
||
}
|
||
}
|
||
|
||
private func pasteEmoji() {
|
||
let target = previousApplication
|
||
let changeCount = pasteboard.changeCount
|
||
dismiss()
|
||
guard let target, !target.isTerminated,
|
||
target.processIdentifier != ProcessInfo.processInfo.processIdentifier,
|
||
AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary)
|
||
else { return }
|
||
Task { [weak self] in
|
||
// Wait for focus restoration; never paste into a different app or a reopened launcher.
|
||
for _ in 0..<20 {
|
||
try? await Task.sleep(for: .milliseconds(25))
|
||
guard let self, self.window?.isVisible == false, !target.isTerminated,
|
||
self.pasteboard.changeCount == changeCount else { return }
|
||
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == target.processIdentifier else { continue }
|
||
guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: true),
|
||
let up = CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: false) else { return }
|
||
down.flags = .maskCommand
|
||
up.flags = .maskCommand
|
||
down.postToPid(target.processIdentifier)
|
||
up.postToPid(target.processIdentifier)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
@objc private func goBackCommand() {
|
||
translation?.goBack()
|
||
emojiPicker?.goBack()
|
||
notes?.goBack()
|
||
}
|
||
|
||
func saveNotes() -> Bool { notes?.save() ?? true }
|
||
|
||
@discardableResult
|
||
private func closeCommand() -> Bool {
|
||
guard notes?.close() != false else { return false }
|
||
translation?.close()
|
||
emojiPicker?.close()
|
||
return true
|
||
}
|
||
|
||
private func runAction(_ command: Application.Command, dismissOnSuccess: Bool) {
|
||
let action: @MainActor () async throws -> Void
|
||
switch command {
|
||
case .toggleTheme: action = toggleSystemTheme
|
||
case .translate, .emoji, .notes:
|
||
preconditionFailure("View commands must be presented, not run as actions")
|
||
}
|
||
if dismissOnSuccess { actions.close(animated: false) }
|
||
isLaunching = true
|
||
updateOpenButton()
|
||
Task {
|
||
do {
|
||
try await action()
|
||
history.recordLaunch(of: Application(command: command))
|
||
isLaunching = false
|
||
if dismissOnSuccess { dismiss() }
|
||
} catch {
|
||
let alert = NSAlert(error: error)
|
||
alert.messageText = "Couldn’t run \(Application(command: command).name)"
|
||
if let window, window.isVisible {
|
||
await alert.beginSheetModal(for: window)
|
||
} else {
|
||
alert.runModal()
|
||
}
|
||
}
|
||
isLaunching = false
|
||
updateOpenButton()
|
||
}
|
||
}
|
||
|
||
private func executeCommand(_ command: Application.Command, dismissOnSuccess: Bool = false) {
|
||
guard command.presentsView else {
|
||
runAction(command, dismissOnSuccess: dismissOnSuccess)
|
||
return
|
||
}
|
||
guard !isCommandOpen, let content = window?.contentView else { return }
|
||
let controller: NSViewController
|
||
let placeholder: String
|
||
let accessibilityLabel: String
|
||
switch command {
|
||
case .translate:
|
||
let tool = TranslationController(defaults: defaults, pasteboard: pasteboard)
|
||
tool.dismiss = { [weak self] in self?.dismiss() }
|
||
translation = tool
|
||
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
|
||
tool.replaceInput = { [weak self] in self?.searchField.stringValue = $0 }
|
||
tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) }
|
||
tool.inputPresentationChanged = { [weak self, weak tool] in
|
||
guard let self, let tool else { return }
|
||
let attributes = self.searchField.placeholderAttributedString?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
||
self.searchField.placeholderAttributedString = NSAttributedString(string: tool.inputPlaceholder, attributes: attributes)
|
||
self.searchField.setAccessibilityLabel(tool.inputAccessibilityLabel)
|
||
self.commandBack.setAccessibilityLabel(tool.backAccessibilityLabel)
|
||
self.commandBack.toolTip = tool.backAccessibilityLabel
|
||
}
|
||
controller = tool
|
||
placeholder = tool.inputPlaceholder
|
||
accessibilityLabel = tool.inputAccessibilityLabel
|
||
case .emoji:
|
||
let tool = EmojiPickerController(defaults: defaults, pasteboard: pasteboard)
|
||
emojiPicker = tool
|
||
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
|
||
tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) }
|
||
tool.copied = { [weak self] in self?.dismiss() }
|
||
tool.pasteRequested = { [weak self] in self?.pasteEmoji() }
|
||
controller = tool
|
||
placeholder = "Search emoji by name or :shortcode:…"
|
||
accessibilityLabel = "Search emoji"
|
||
case .notes:
|
||
let tool = NotesController(defaults: defaults, directory: notesDirectory)
|
||
notes = tool
|
||
controller = tool
|
||
placeholder = "Conjure Notes"
|
||
accessibilityLabel = "Conjure Notes"
|
||
case .toggleTheme:
|
||
preconditionFailure("Action commands must run without presenting a view")
|
||
}
|
||
activeCommand = command
|
||
history.recordLaunch(of: Application(command: command))
|
||
let previousQuery = searchField.stringValue
|
||
let previousFrame = window?.frame
|
||
let previousPlaceholder = searchField.placeholderAttributedString
|
||
let close: () -> Void = { [weak self, weak controller] in
|
||
guard let self, let controller else { return }
|
||
controller.view.removeFromSuperview()
|
||
self.translation?.providerControl.removeFromSuperview()
|
||
self.translationHeader.isHidden = true
|
||
self.translation = nil
|
||
self.emojiPicker = nil
|
||
self.notes = nil
|
||
self.searchField.isHidden = false
|
||
self.launcherViews.first?.isHidden = false
|
||
self.launcherViews.first?.setAccessibilityHidden(false)
|
||
if command == .notes, let previousFrame, let window = self.window {
|
||
let frame = LauncherAlignment.restoredFrame(
|
||
savedCenter: CGPoint(x: window.frame.midX, y: window.frame.midY),
|
||
windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame)
|
||
window.styleMask = [.borderless]
|
||
window.minSize = .zero
|
||
window.appearance = NSAppearance(named: .darkAqua)
|
||
window.setFrame(frame, display: true)
|
||
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
|
||
}
|
||
self.activeCommand = nil
|
||
self.commandBack.isHidden = true
|
||
self.commandBack.target = nil
|
||
self.searchSymbol.isHidden = false
|
||
for view in self.launcherViews.dropFirst() {
|
||
view.isHidden = false
|
||
view.setAccessibilityHidden(false)
|
||
view.alphaValue = 1
|
||
}
|
||
self.searchField.placeholderAttributedString = previousPlaceholder
|
||
self.searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
|
||
self.searchField.stringValue = previousQuery
|
||
self.openButton.keyEquivalent = "\r"
|
||
self.updateResults()
|
||
self.window?.makeFirstResponder(self.searchField)
|
||
}
|
||
translation?.closed = close
|
||
emojiPicker?.closed = close
|
||
notes?.closed = close
|
||
actions.close(animated: false)
|
||
(window as? LauncherWindow)?.resetNumberHints()
|
||
openButton.keyEquivalent = ""
|
||
searchSymbol.isHidden = true
|
||
commandBack.setAccessibilityLabel("Back to applications")
|
||
commandBack.toolTip = "Back to applications"
|
||
commandBack.target = self
|
||
commandBack.action = #selector(goBackCommand)
|
||
commandBack.isHidden = false
|
||
searchField.stringValue = ""
|
||
searchField.placeholderAttributedString = NSAttributedString(
|
||
string: placeholder,
|
||
attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:]
|
||
)
|
||
searchField.setAccessibilityLabel(accessibilityLabel)
|
||
if command == .notes {
|
||
let savedSize = defaults.string(forKey: Self.notesSizeKey).map(NSSizeFromString)
|
||
?? NSSize(width: 570, height: 404)
|
||
window?.appearance = nil
|
||
window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView]
|
||
window?.minSize = NSSize(width: 570, height: 260)
|
||
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||
window?.standardWindowButton(.zoomButton)?.isHidden = true
|
||
launcherViews.first?.isHidden = true
|
||
launcherViews.first?.setAccessibilityHidden(true)
|
||
if let previousFrame, let window {
|
||
let visibleFrame = window.screen?.visibleFrame ?? previousFrame
|
||
let size = NSSize(width: min(max(savedSize.width, 570), visibleFrame.width),
|
||
height: min(max(savedSize.height, 260), visibleFrame.height))
|
||
let frame = LauncherAlignment.restoredFrame(
|
||
savedCenter: CGPoint(x: previousFrame.midX, y: previousFrame.midY),
|
||
windowSize: size, visibleFrame: visibleFrame)
|
||
window.setFrame(frame, display: true)
|
||
}
|
||
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 14) }
|
||
}
|
||
for view in launcherViews.dropFirst() {
|
||
view.isHidden = true
|
||
view.setAccessibilityHidden(true)
|
||
}
|
||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||
content.addSubview(controller.view)
|
||
if let translation {
|
||
translationHeader.addArrangedSubview(translation.providerControl)
|
||
translationHeader.isHidden = false
|
||
}
|
||
NSLayoutConstraint.activate([
|
||
controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||
controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||
command == .notes
|
||
? controller.view.topAnchor.constraint(equalTo: content.topAnchor)
|
||
: controller.view.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 16),
|
||
controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor),
|
||
])
|
||
if let notes { notes.focusEditor() }
|
||
else { window?.makeFirstResponder(searchField) }
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class ApplicationCell: NSTableCellView {
|
||
private static let hintOffset: CGFloat = 14
|
||
private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor]
|
||
private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor]
|
||
private static let timing = CAMediaTimingFunction(name: .easeOut)
|
||
static let applicationKind = NSAttributedString(string: "Application", attributes: [
|
||
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: SunsetPalette.secondaryText
|
||
])
|
||
static let commandKind = NSAttributedString(string: "Command", attributes: applicationKind.attributes(at: 0, effectiveRange: nil))
|
||
static let favoriteKind = favorited(applicationKind)
|
||
static let favoriteCommandKind = favorited(commandKind)
|
||
|
||
private static func favorited(_ kind: NSAttributedString) -> NSAttributedString {
|
||
let font = kind.attribute(.font, at: 0, effectiveRange: nil) as! NSFont
|
||
let attachment = NSTextAttachment()
|
||
attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")?
|
||
.withSymbolConfiguration(IconStyle.symbolConfiguration(for: font)
|
||
.applying(.init(scale: .small))
|
||
.applying(.init(paletteColors: [SunsetPalette.secondaryText])))
|
||
let size = attachment.image?.size ?? .zero
|
||
attachment.bounds = NSRect(x: 0, y: (font.capHeight - size.height) / 2,
|
||
width: size.width, height: size.height)
|
||
let title = NSMutableAttributedString(attachment: attachment)
|
||
title.append(NSAttributedString(string: " "))
|
||
title.append(kind)
|
||
return title
|
||
}
|
||
|
||
let locationLabel = NSTextField(labelWithString: "")
|
||
let kindLabel = NSTextField(labelWithString: "Application")
|
||
let numberHint = ShortcutIndicator(
|
||
"1", backgroundColor: NSColor(srgbRed: 0.26, green: 0.22, blue: 0.19, alpha: 0.5),
|
||
fontSize: 10, keySize: 20
|
||
)
|
||
private let kindFade = CAGradientLayer()
|
||
private var numberHintVisible = false
|
||
private var hintGeneration = 0
|
||
|
||
override init(frame frameRect: NSRect) {
|
||
super.init(frame: frameRect)
|
||
let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
|
||
let icon = IconView(font: font)
|
||
let label = NSTextField(labelWithString: "")
|
||
label.font = font
|
||
label.textColor = SunsetPalette.text
|
||
label.lineBreakMode = .byTruncatingTail
|
||
locationLabel.font = .systemFont(ofSize: 12)
|
||
locationLabel.textColor = SunsetPalette.secondaryText
|
||
locationLabel.lineBreakMode = .byTruncatingMiddle
|
||
locationLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||
locationLabel.isHidden = true
|
||
let title = NSStackView(views: [label, locationLabel])
|
||
title.orientation = .horizontal
|
||
title.alignment = .centerY
|
||
title.spacing = 8
|
||
kindLabel.font = .systemFont(ofSize: font.pointSize)
|
||
kindLabel.textColor = SunsetPalette.secondaryText
|
||
kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||
kindLabel.setAccessibilityElement(false)
|
||
kindLabel.wantsLayer = true
|
||
imageView = icon
|
||
textField = label
|
||
numberHint.wantsLayer = true
|
||
numberHint.isHidden = true
|
||
numberHint.layer?.opacity = 0
|
||
numberHint.layer?.transform = CATransform3DMakeTranslation(Self.hintOffset, 0, 0)
|
||
kindFade.colors = Self.opaqueMask
|
||
kindFade.transform = CATransform3DMakeTranslation(Self.hintOffset, 0, 0)
|
||
// Added last so the badge overlays the label without changing the row's layout.
|
||
for view in [icon, title, kindLabel, numberHint] {
|
||
view.translatesAutoresizingMaskIntoConstraints = false
|
||
addSubview(view)
|
||
}
|
||
NSLayoutConstraint.activate([
|
||
icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
|
||
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
|
||
title.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
title.trailingAnchor.constraint(lessThanOrEqualTo: kindLabel.leadingAnchor, constant: -12),
|
||
kindLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
kindLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||
numberHint.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||
numberHint.centerYAnchor.constraint(equalTo: centerYAnchor),
|
||
])
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("Conjure creates application cells programmatically") }
|
||
|
||
override func layout() {
|
||
super.layout()
|
||
updateKindFadeGeometry()
|
||
}
|
||
|
||
func setNumberHint(_ key: String?, animated: Bool = true) {
|
||
if let key { numberHint.setSingleKey(key) }
|
||
let visible = key != nil
|
||
let animate = animated && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
|
||
if animate && visible == numberHintVisible { return }
|
||
numberHintVisible = visible
|
||
hintGeneration += 1
|
||
let generation = hintGeneration
|
||
if animate { layoutSubtreeIfNeeded() }
|
||
updateKindFadeGeometry()
|
||
guard let hintLayer = numberHint.layer, let labelLayer = kindLabel.layer else { return }
|
||
let hintPresentation = hintLayer.presentation() ?? hintLayer
|
||
let fadePresentation = kindFade.presentation() ?? kindFade
|
||
let hintOffset = hintPresentation.transform.m41
|
||
let hintOpacity = hintPresentation.opacity
|
||
let fadeOffset = fadePresentation.transform.m41
|
||
let fadeColors = fadePresentation.colors
|
||
let targetOffset: CGFloat = visible ? 0 : Self.hintOffset
|
||
let targetOpacity: Float = visible ? 1 : 0
|
||
let targetColors = visible ? Self.fadedMask : Self.opaqueMask
|
||
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
hintLayer.removeAllAnimations()
|
||
kindFade.removeAllAnimations()
|
||
kindLabel.isHidden = false
|
||
numberHint.isHidden = !(visible || animate)
|
||
labelLayer.mask = visible || animate ? kindFade : nil
|
||
hintLayer.transform = CATransform3DMakeTranslation(targetOffset, 0, 0)
|
||
hintLayer.opacity = targetOpacity
|
||
kindFade.transform = CATransform3DMakeTranslation(targetOffset, 0, 0)
|
||
kindFade.colors = targetColors
|
||
if animate {
|
||
if !visible {
|
||
CATransaction.setCompletionBlock { [weak self] in
|
||
MainActor.assumeIsolated {
|
||
guard let self, self.hintGeneration == generation, !self.numberHintVisible else { return }
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
self.numberHint.isHidden = true
|
||
self.kindLabel.layer?.mask = nil
|
||
CATransaction.commit()
|
||
}
|
||
}
|
||
}
|
||
animateLayer(hintLayer, key: "transform.translation.x", from: hintOffset, to: targetOffset)
|
||
animateLayer(hintLayer, key: "opacity", from: hintOpacity, to: targetOpacity)
|
||
animateLayer(kindFade, key: "transform.translation.x", from: fadeOffset, to: targetOffset)
|
||
animateLayer(kindFade, key: "colors", from: fadeColors, to: targetColors)
|
||
}
|
||
CATransaction.commit()
|
||
}
|
||
|
||
private func updateKindFadeGeometry() {
|
||
let padding = Self.hintOffset
|
||
let width = kindLabel.bounds.width + padding * 2
|
||
guard width > padding * 2 else { return }
|
||
let badgeLeading = numberHint.frame.minX - kindLabel.frame.minX + padding
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
// Extend the mask so its moving left edge never clips the start of the label.
|
||
kindFade.bounds = CGRect(x: 0, y: 0, width: width, height: kindLabel.bounds.height)
|
||
kindFade.position = CGPoint(x: kindLabel.bounds.midX, y: kindLabel.bounds.midY)
|
||
kindFade.startPoint = CGPoint(x: max(0, min(1, (badgeLeading - 36) / width)), y: 0.5)
|
||
kindFade.endPoint = CGPoint(x: max(0, min(1, (badgeLeading + 2) / width)), y: 0.5)
|
||
CATransaction.commit()
|
||
}
|
||
|
||
private func animateLayer(_ layer: CALayer, key: String, from: Any?, to: Any) {
|
||
let animation = CABasicAnimation(keyPath: key)
|
||
animation.fromValue = from
|
||
animation.toValue = to
|
||
animation.duration = 0.16
|
||
animation.timingFunction = Self.timing
|
||
layer.add(animation, forKey: key)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class LauncherScrollView: NSScrollView {
|
||
static let fadeHeight: CGFloat = 32
|
||
var edgeFadeHeight: CGFloat = LauncherScrollView.fadeHeight {
|
||
didSet { needsLayout = true }
|
||
}
|
||
private let edgeFade = CAGradientLayer()
|
||
|
||
override init(frame frameRect: NSRect) {
|
||
super.init(frame: frameRect)
|
||
wantsLayer = true
|
||
edgeFade.colors = [NSColor.clear.cgColor, NSColor.black.cgColor,
|
||
NSColor.black.cgColor, NSColor.clear.cgColor]
|
||
edgeFade.startPoint = CGPoint(x: 0.5, y: 0)
|
||
edgeFade.endPoint = CGPoint(x: 0.5, y: 1)
|
||
layer?.mask = edgeFade
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("Conjure creates its scroll view programmatically") }
|
||
|
||
override func layout() {
|
||
super.layout()
|
||
let fade = min(0.5, edgeFadeHeight / max(bounds.height, 1))
|
||
CATransaction.begin()
|
||
CATransaction.setDisableActions(true)
|
||
edgeFade.frame = bounds
|
||
edgeFade.locations = [0, NSNumber(value: Double(fade)), NSNumber(value: Double(1 - fade)), 1]
|
||
CATransaction.commit()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private final class ApplicationTableView: NSTableView {
|
||
var openSelection: (() -> Void)?
|
||
var dismiss: (() -> Void)?
|
||
var editSearch: ((NSEvent) -> Void)?
|
||
|
||
override func keyDown(with event: NSEvent) {
|
||
switch event.keyCode {
|
||
case 36, 76:
|
||
openSelection?()
|
||
case 53:
|
||
dismiss?()
|
||
default:
|
||
let isEditing = event.keyCode == 51 || event.keyCode == 117
|
||
|| (event.characters?.unicodeScalars.contains { !CharacterSet.controlCharacters.contains($0) } ?? false)
|
||
if isEditing && event.modifierFlags.intersection([.command, .control]).isEmpty {
|
||
editSearch?(event)
|
||
} else {
|
||
super.keyDown(with: event)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class LauncherWindow: NSWindow {
|
||
override var canBecomeKey: Bool { true }
|
||
override var canBecomeMain: Bool { true }
|
||
var navigateSelection: ((Int) -> Void)?
|
||
var dismiss: (() -> Void)?
|
||
var escape: (() -> Void)?
|
||
var dragEnded: (() -> Void)?
|
||
var canUseNumberShortcuts: (() -> Bool)?
|
||
var numberHintsChanged: (() -> Void)?
|
||
var launchNumber: ((Int) -> Void)?
|
||
var commandNavigation: ((String) -> Bool)?
|
||
var commandShortcut: ((NSEvent) -> Bool)?
|
||
var usesTextNavigation: (() -> Bool)?
|
||
private var numberShortcuts = CommandNumberShortcuts()
|
||
private var commandHoldTask: Task<Void, Never>?
|
||
var numberHintsVisible: Bool { numberShortcuts.isVisible }
|
||
|
||
private func commandOnly(_ flags: NSEvent.ModifierFlags) -> Bool {
|
||
flags.intersection([.command, .control, .option, .shift, .function]) == .command
|
||
}
|
||
|
||
private var numberShortcutsEligible: Bool {
|
||
isKeyWindow && isVisible && attachedSheet == nil && canUseNumberShortcuts?() == true
|
||
}
|
||
|
||
func beginNumberHintSession() {
|
||
resetNumberHints()
|
||
numberShortcuts.reset()
|
||
}
|
||
|
||
func sampleNumberHints() {
|
||
reevaluateNumberHints(
|
||
modifierFlags: NSEvent.modifierFlags,
|
||
eligible: numberShortcutsEligible,
|
||
at: ProcessInfo.processInfo.systemUptime
|
||
)
|
||
}
|
||
|
||
/// The same sampling path accepts fixed flags, eligibility, and uptime for native smoke checks.
|
||
func reevaluateNumberHints(modifierFlags: NSEvent.ModifierFlags, eligible: Bool, at time: TimeInterval) {
|
||
let wasVisible = numberShortcuts.isVisible
|
||
let began = numberShortcuts.update(commandOnly: commandOnly(modifierFlags), eligible: eligible, at: time)
|
||
if !numberShortcuts.isHolding {
|
||
commandHoldTask?.cancel()
|
||
commandHoldTask = nil
|
||
} else if began {
|
||
commandHoldTask = Task { [weak self] in
|
||
do { try await Task.sleep(for: .seconds(1)) } catch { return }
|
||
self?.finishCommandHold(at: ProcessInfo.processInfo.systemUptime)
|
||
}
|
||
}
|
||
if wasVisible != numberShortcuts.isVisible { numberHintsChanged?() }
|
||
}
|
||
|
||
func resetNumberHints(modifierFlags: NSEvent.ModifierFlags = NSEvent.modifierFlags) {
|
||
commandHoldTask?.cancel()
|
||
commandHoldTask = nil
|
||
let wasVisible = numberShortcuts.isVisible
|
||
if commandOnly(modifierFlags) { numberShortcuts.cancel() }
|
||
else { numberShortcuts.reset() }
|
||
if wasVisible { numberHintsChanged?() }
|
||
}
|
||
|
||
func finishCommandHold(
|
||
at time: TimeInterval,
|
||
modifierFlags: NSEvent.ModifierFlags = NSEvent.modifierFlags,
|
||
eligible: Bool? = nil
|
||
) {
|
||
commandHoldTask?.cancel()
|
||
commandHoldTask = nil
|
||
let wasVisible = numberShortcuts.isVisible
|
||
_ = numberShortcuts.update(
|
||
commandOnly: commandOnly(modifierFlags), eligible: eligible ?? numberShortcutsEligible, at: time
|
||
)
|
||
_ = numberShortcuts.reveal(at: time)
|
||
if wasVisible != numberShortcuts.isVisible { numberHintsChanged?() }
|
||
}
|
||
|
||
override func becomeKey() {
|
||
super.becomeKey()
|
||
sampleNumberHints()
|
||
}
|
||
|
||
override func beginSheet(_ sheetWindow: NSWindow, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) {
|
||
resetNumberHints()
|
||
super.beginSheet(sheetWindow, completionHandler: handler)
|
||
}
|
||
|
||
override func resignKey() {
|
||
resetNumberHints()
|
||
super.resignKey()
|
||
}
|
||
|
||
override func orderOut(_ sender: Any?) {
|
||
resetNumberHints()
|
||
super.orderOut(sender)
|
||
}
|
||
|
||
override func sendEvent(_ event: NSEvent) {
|
||
if event.type == .keyDown, event.keyCode == 53, attachedSheet == nil,
|
||
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty {
|
||
if let editor = firstResponder as? NotesTextView, editor.vimEnabled, editor.hasMarkedText() {
|
||
super.sendEvent(event)
|
||
return
|
||
}
|
||
cancelOperation(nil)
|
||
return
|
||
}
|
||
let eligible = numberShortcutsEligible
|
||
if event.type == .flagsChanged || (event.type == .keyUp && event.keyCode == 49) {
|
||
reevaluateNumberHints(modifierFlags: event.modifierFlags, eligible: eligible, at: event.timestamp)
|
||
} else if event.type == .keyDown {
|
||
if eligible, commandOnly(event.modifierFlags),
|
||
let index = numberShortcuts.index(for: event.charactersIgnoringModifiers ?? "") {
|
||
resetNumberHints()
|
||
if !event.isARepeat { launchNumber?(index) }
|
||
return
|
||
}
|
||
resetNumberHints(modifierFlags: event.modifierFlags)
|
||
}
|
||
if event.type == .keyDown, attachedSheet == nil,
|
||
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
|
||
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
|
||
if usesTextNavigation?() != true {
|
||
switch event.charactersIgnoringModifiers?.lowercased() {
|
||
case "j":
|
||
navigateSelection?(1)
|
||
return
|
||
case "k":
|
||
navigateSelection?(-1)
|
||
return
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
}
|
||
super.sendEvent(event)
|
||
if event.type == .leftMouseUp || (event.type == .leftMouseDown && NSEvent.pressedMouseButtons & 1 == 0) {
|
||
dragEnded?()
|
||
}
|
||
}
|
||
|
||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||
if attachedSheet == nil, commandShortcut?(event) == true {
|
||
return true
|
||
}
|
||
return super.performKeyEquivalent(with: event)
|
||
}
|
||
|
||
override func cancelOperation(_ sender: Any?) {
|
||
if let escape { escape() } else { dismiss?() }
|
||
}
|
||
|
||
override func performClose(_ sender: Any?) {
|
||
dismiss?()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class LauncherRowView: NSTableRowView {
|
||
static let rowHeight: CGFloat = 38
|
||
static let textSize: CGFloat = 14
|
||
static let iconToTextSpacing: CGFloat = 12
|
||
var topSpacing: CGFloat = 0 {
|
||
didSet { needsLayout = true; needsDisplay = true }
|
||
}
|
||
|
||
override func layout() {
|
||
super.layout()
|
||
for case let cell as ApplicationCell in subviews {
|
||
cell.frame = NSRect(x: 0, y: topSpacing, width: bounds.width, height: bounds.height - topSpacing)
|
||
}
|
||
}
|
||
|
||
override func drawSelection(in dirtyRect: NSRect) {
|
||
var selectionBounds = bounds
|
||
selectionBounds.origin.y += topSpacing
|
||
selectionBounds.size.height -= topSpacing
|
||
let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10)
|
||
SunsetPalette.selectionFill.setFill()
|
||
selection.fill()
|
||
let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast
|
||
(increasedContrast ? SunsetPalette.accent : SunsetPalette.selectionBorder).setStroke()
|
||
selection.lineWidth = increasedContrast ? 1.5 : 0.5
|
||
selection.stroke()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
func roundMaterialCorners(_ view: NSVisualEffectView, radius: CGFloat) {
|
||
let size = NSSize(width: radius * 2 + 1, height: radius * 2 + 1)
|
||
let mask = NSImage(size: size, flipped: false) { rect in
|
||
NSColor.black.setFill()
|
||
NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill()
|
||
return true
|
||
}
|
||
mask.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius)
|
||
mask.resizingMode = .stretch
|
||
view.wantsLayer = true
|
||
view.layer?.cornerRadius = radius
|
||
view.layer?.masksToBounds = true
|
||
view.maskImage = mask
|
||
}
|
||
|