27c203b336
The 32-point viewport fade covered launcher rows with only 16 points of inset and emoji with only 12. Match their top spacing to the shared fade height while keeping the viewport mask fixed during scrolling.
1312 lines
60 KiB
Swift
1312 lines
60 KiB
Swift
import AppKit
|
||
import ConjureKit
|
||
import CoreText
|
||
|
||
@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 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 var applications: [Application] = Application.commands
|
||
private var results: [Application] = []
|
||
private var calculation: CalculatorResult?
|
||
private var isCurrencyCalculation = false
|
||
private var currencyRates: CurrencyRates?
|
||
private var currencyFetchedAt: Date?
|
||
private var currencyTask: Task<Void, Never>?
|
||
private var currencyError: String?
|
||
private var currencyFailureQuery: String?
|
||
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 isCommandOpen: Bool { translation != nil || emojiPicker != nil }
|
||
private var activeCommand: Application.Command?
|
||
private var pendingPresentation: (() -> Void)?
|
||
private static let emojiIcon: NSImage = {
|
||
let text = NSAttributedString(string: "🤔", 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()) {
|
||
self.history = history
|
||
self.uninstaller = uninstaller
|
||
let window = LauncherPanel(
|
||
contentRect: NSRect(x: 0, y: 0, width: 750, height: 474),
|
||
styleMask: [.borderless],
|
||
backing: .buffered,
|
||
defer: false
|
||
)
|
||
window.title = "Conjure"
|
||
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?.emojiPicker?.handleControlKey(key) ?? false }
|
||
window.dismiss = { [weak self] in self?.dismiss() }
|
||
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"
|
||
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
|
||
else { self.dismiss(restoringFocus: false) }
|
||
}
|
||
restorePosition()
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("Conjure creates its window programmatically")
|
||
}
|
||
|
||
deinit {
|
||
currencyTask?.cancel()
|
||
}
|
||
|
||
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
|
||
}
|
||
closeCommand()
|
||
actions.close()
|
||
(window as? LauncherPanel)?.beginNumberHintSession()
|
||
let frontmost = NSWorkspace.shared.frontmostApplication
|
||
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
|
||
previousApplication = frontmost
|
||
}
|
||
searchField.stringValue = ""
|
||
restorePosition()
|
||
updateResults()
|
||
window?.deminiaturize(nil)
|
||
showWindow(nil)
|
||
NSApp.activate()
|
||
window?.makeKeyAndOrderFront(nil)
|
||
window?.makeFirstResponder(searchField)
|
||
(window as? LauncherPanel)?.sampleNumberHints()
|
||
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||
refreshApplications()
|
||
if let command {
|
||
showCommand(command)
|
||
history.recordLaunch(of: Application(command: command))
|
||
}
|
||
}
|
||
|
||
func toggle(command: Application.Command? = nil) {
|
||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||
if window?.isVisible == true, window?.isKeyWindow == true,
|
||
launcherFrameBeforeReview == nil, activeCommand == command {
|
||
closeCommand()
|
||
actions.close()
|
||
dismiss()
|
||
} else {
|
||
present(command: command)
|
||
}
|
||
}
|
||
|
||
func dismiss(restoringFocus: Bool = true) {
|
||
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
|
||
if let review = uninstallReview { review.goBack(); return }
|
||
if isCommandOpen {
|
||
goBackCommand()
|
||
if restoringFocus { return }
|
||
}
|
||
guard launcherFrameBeforeReview == nil else { return }
|
||
actions.close()
|
||
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 (Escape)"
|
||
commandBack.isHidden = true
|
||
NSLayoutConstraint.activate([
|
||
commandBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
||
commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)),
|
||
])
|
||
let header = NSStackView(views: [searchSymbol, commandBack, searchField])
|
||
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 refreshCurrencyRates() {
|
||
guard currencyTask == nil else { return }
|
||
currencyTask = Task { [weak self] in
|
||
let result: Result<CurrencyRates, any Error>
|
||
do { result = .success(try await CurrencyAPI.fetch()) }
|
||
catch { result = .failure(error) }
|
||
guard !Task.isCancelled, let self else { return }
|
||
self.currencyTask = nil
|
||
switch result {
|
||
case .success(let rates):
|
||
self.currencyRates = rates
|
||
self.currencyFetchedAt = Date()
|
||
self.currencyError = nil
|
||
self.currencyFailureQuery = nil
|
||
case .failure(let error):
|
||
self.currencyError = error.localizedDescription
|
||
self.currencyFailureQuery = self.searchField.stringValue
|
||
}
|
||
self.updateResults(preservingSelection: true, allowingCurrencyFetch: false)
|
||
}
|
||
}
|
||
|
||
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, allowingCurrencyFetch: Bool = true) {
|
||
guard !isCommandOpen else { return }
|
||
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
|
||
? results[tableView.selectedRow].id : nil
|
||
let query = searchField.stringValue
|
||
if currencyFailureQuery != query {
|
||
currencyError = nil
|
||
currencyFailureQuery = nil
|
||
}
|
||
isCurrencyCalculation = Calculator.isCurrencyQuery(query, currencyRates: currencyRates)
|
||
let needsCurrencyRefresh = isCurrencyCalculation && (currencyFetchedAt.map {
|
||
!Calendar.current.isDate($0, inSameDayAs: Date())
|
||
} ?? true)
|
||
if allowingCurrencyFetch, needsCurrencyRefresh, currencyError == nil {
|
||
refreshCurrencyRates()
|
||
}
|
||
calculation = Calculator.evaluate(query, currencyRates: currencyRates)
|
||
if isCurrencyCalculation, let currencyError {
|
||
let cached = currencyRates.map { " Cached snapshot \($0.date) is not used." } ?? ""
|
||
calculation = CalculatorResult(
|
||
displayValue: "Currency rates unavailable", copyValue: nil,
|
||
detail: "Daily rates: \(currencyError). Change the query to retry.\(cached)"
|
||
)
|
||
} else if isCurrencyCalculation, currencyRates != nil, let answer = calculation {
|
||
let cacheStatus = needsCurrencyRefresh
|
||
? (currencyTask == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…")
|
||
: "Cached daily rates"
|
||
calculation = CalculatorResult(
|
||
displayValue: answer.displayValue, copyValue: answer.copyValue,
|
||
detail: "\(answer.detail) · \(cacheStatus)"
|
||
)
|
||
}
|
||
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()
|
||
}
|
||
|
||
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||
if let emojiPicker { return emojiPicker.handle(commandSelector) }
|
||
if let translation {
|
||
if commandSelector == #selector(NSResponder.insertNewline(_:)) { translation.translate(); return true }
|
||
if commandSelector == #selector(NSResponder.cancelOperation(_:)) { translation.goBack(); return true }
|
||
return false
|
||
}
|
||
switch commandSelector {
|
||
case #selector(NSResponder.moveDown(_:)):
|
||
moveSelection(by: 1)
|
||
case #selector(NSResponder.moveUp(_:)):
|
||
moveSelection(by: -1)
|
||
case #selector(NSResponder.insertNewline(_:)):
|
||
openSelection()
|
||
case #selector(NSResponder.cancelOperation(_:)):
|
||
dismiss()
|
||
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
|
||
cell.imageView?.image = entry.command == .emoji ? Self.emojiIcon
|
||
: NSImage(systemSymbolName: "character.bubble", accessibilityDescription: nil)
|
||
cell.imageView?.contentTintColor = entry.command == .emoji ? nil : SunsetPalette.accent
|
||
cell.toolTip = entry.command == .emoji ? "Search and copy emoji" : "Translate English to Japanese"
|
||
} 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 }
|
||
let pasteboard = NSPasteboard.general
|
||
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()
|
||
}
|
||
}
|
||
|
||
@objc private func goBackCommand() {
|
||
translation?.goBack()
|
||
emojiPicker?.goBack()
|
||
}
|
||
|
||
private func closeCommand() {
|
||
translation?.goBack()
|
||
emojiPicker?.close()
|
||
}
|
||
|
||
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()
|
||
translation = tool
|
||
tool.input = { [weak self] in self?.searchField.stringValue ?? "" }
|
||
controller = tool
|
||
placeholder = "Enter English text to translate…"
|
||
accessibilityLabel = "English text to translate into Japanese"
|
||
case .emoji:
|
||
let tool = EmojiPickerController()
|
||
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() }
|
||
controller = tool
|
||
placeholder = "Search emoji by name or :shortcode:…"
|
||
accessibilityLabel = "Search emoji"
|
||
}
|
||
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 = nil
|
||
self.emojiPicker = nil
|
||
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
|
||
actions.close()
|
||
(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)
|
||
for view in launcherViews.dropFirst() {
|
||
view.isHidden = true
|
||
view.setAccessibilityHidden(true)
|
||
}
|
||
controller.view.translatesAutoresizingMaskIntoConstraints = false
|
||
controller.view.alphaValue = 0
|
||
content.addSubview(controller.view)
|
||
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),
|
||
])
|
||
NSAnimationContext.runAnimationGroup { context in
|
||
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.18
|
||
controller.view.animator().alphaValue = 1
|
||
}
|
||
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 dragEnded: (() -> Void)?
|
||
var canUseNumberShortcuts: (() -> Bool)?
|
||
var numberHintsChanged: (() -> Void)?
|
||
var launchNumber: ((Int) -> Void)?
|
||
var commandNavigation: ((String) -> 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) {
|
||
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 }
|
||
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 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
|
||
}
|
||
|