Files
conjure/Sources/Conjure/LauncherWindowController.swift
T

1357 lines
62 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 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 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) {
self.history = history
self.uninstaller = uninstaller
self.defaults = defaults
self.pasteboard = pasteboard
self.notesDirectory = notesDirectory
let window = LauncherPanel(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.title = "Conjure"
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?.translation?.handleControlKey(key) ?? self?.emojiPicker?.handleControlKey(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 launcherFrameBeforeReview != nil {
pendingPresentation = { [weak self] in self?.present(command: command) }
uninstallReview?.goBack()
return
}
guard closeCommand() else { return }
actions.close(animated: false)
(window as? LauncherPanel)?.beginNumberHintSession()
let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
previousApplication = frontmost
}
searchField.stringValue = ""
restorePosition()
updateResults()
if let command {
showCommand(command)
history.recordLaunch(of: Application(command: command))
}
window?.deminiaturize(nil)
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
if let notes { notes.focusEditor() }
else { window?.makeFirstResponder(searchField) }
(window as? LauncherPanel)?.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 }
UserDefaults.standard.set([
"display": identifier, "x": window.frame.minX, "y": window.frame.minY
], forKey: Self.positionKey)
}
private func restorePosition() {
guard let window else { return }
let saved = UserDefaults.standard.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 origin: CGPoint?
if savedScreen != nil, let x = saved?["x"] as? Double, let y = saved?["y"] as? Double {
origin = CGPoint(x: x, y: y)
}
let frame = LauncherAlignment.restoredFrame(
savedOrigin: origin, 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 windowDidResignKey(_ notification: Notification) {
(window as? LauncherPanel)?.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.isBordered = false
openButton.font = .systemFont(ofSize: 12, weight: .medium)
openButton.contentTintColor = SunsetPalette.text
openButton.keyEquivalent = "\r"
openButton.target = self
openButton.action = #selector(openSelection)
openButton.isEnabled = false
actionsButton.isBordered = false
actionsButton.title = "Actions"
actionsButton.font = .systemFont(ofSize: 12, weight: .medium)
actionsButton.contentTintColor = SunsetPalette.text
actionsButton.keyEquivalent = "k"
actionsButton.keyEquivalentModifierMask = .command
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 returnKey = ShortcutIndicator("↵")
let actionKeys = ShortcutIndicator("⌘", "K")
let actionPill = NSView()
actionPill.wantsLayer = true
actionPill.layer?.cornerRadius = 18
actionPill.layer?.borderWidth = 0.5
for view in [openButton, returnKey, actionsButton, actionKeys] {
view.translatesAutoresizingMaskIntoConstraints = false
actionPill.addSubview(view)
}
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: content.trailingAnchor, constant: -24),
header.heightAnchor.constraint(equalToConstant: 32),
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),
actionPill.heightAnchor.constraint(equalToConstant: 36),
openButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14),
openButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.leadingAnchor.constraint(equalTo: openButton.trailingAnchor, constant: 8),
actionsButton.leadingAnchor.constraint(equalTo: returnKey.trailingAnchor, constant: 14),
actionKeys.leadingAnchor.constraint(equalTo: actionsButton.trailingAnchor, constant: 8),
actionKeys.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
actionKeys.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
actionsButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
])
window.effectiveAppearance.performAsCurrentDrawingAppearance {
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
content.layer?.borderColor = border
actionPill.layer?.borderColor = SunsetPalette.accent.withAlphaComponent(0.35).cgColor
actionPill.layer?.backgroundColor = SunsetPalette.background.withAlphaComponent(0.25).cgColor
}
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? LauncherPanel)?.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? LauncherPanel)?.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) || 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 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"
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? LauncherPanel)?.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? LauncherPanel)?.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? LauncherPanel)?.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 {
showCommand(command)
history.recordLaunch(of: application)
return
}
(window as? LauncherPanel)?.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 saveNotes() else { return false }
notes?.closed?()
translation?.close()
emojiPicker?.close()
return true
}
private func showCommand(_ command: Application.Command) {
guard !isCommandOpen, let content = window?.contentView else { return }
activeCommand = command
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.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) }
tool.languagesChanged = { [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)
}
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"
}
let previousQuery = searchField.stringValue
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.isEnabled = true
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? LauncherPanel)?.resetNumberHints()
openButton.keyEquivalent = ""
searchSymbol.isHidden = true
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 {
searchField.stringValue = "Conjure Notes"
searchField.isEnabled = false
}
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),
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
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, Self.fadeHeight / 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 LauncherPanel: NSPanel {
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 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 {
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, usesTextNavigation?() != true,
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return }
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 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
}