Files
conjure/Sources/Conjure/LauncherWindowController.swift
T

718 lines
32 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
@MainActor
final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
private let searchField = NSSearchField()
private let tableView = ApplicationTableView()
private let statusLabel = NSTextField(labelWithString: "Finding applications…")
private let emptyLabel = NSTextField(labelWithString: "Finding applications…")
private let openButton = NSButton(title: "Open Application", target: nil, action: nil)
private let history: LaunchHistory
private var applications: [Application] = []
private var results: [Application] = []
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?
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 { self.moveSelection(by: offset) }
}
window.dismiss = { [weak self] in self?.dismiss() }
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")
}
func present() {
if launcherFrameBeforeReview != nil {
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
return
}
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)
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
refreshApplications()
}
func toggle() {
guard !isLaunching, window?.attachedSheet == nil else { return }
if window?.isVisible == true, window?.isKeyWindow == true {
dismiss()
} else {
present()
}
}
func dismiss(restoringFocus: Bool = true) {
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
if let review = uninstallReview { review.goBack(); 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) {
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 for apps…",
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")
let symbol = NSImageView()
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
symbol.contentTintColor = SunsetPalette.accent
symbol.setAccessibilityElement(false)
let header = NSStackView(views: [symbol, searchField])
header.orientation = .horizontal
header.alignment = .centerY
header.spacing = 12
let sectionLabel = NSTextField(labelWithString: "Applications")
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 = NSScrollView()
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
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("Application actions")
actionsButton.toolTip = "Actions for the selected application (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 = NSVisualEffectView()
actionPill.material = .popover
actionPill.blendingMode = .withinWindow
actionPill.state = .active
roundMaterialCorners(actionPill, radius: 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 [header, sectionLabel, scrollView, 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),
symbol.widthAnchor.constraint(equalToConstant: 22),
symbol.heightAnchor.constraint(equalToConstant: 22),
sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16),
sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 6),
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.accent.withAlphaComponent(0.08).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
self.updateLocationLabels()
self.discoveryTask = nil
self.updateResults(preservingSelection: true)
}
}
private func updateLocationLabels() {
var nameCounts: [String: Int] = [:]
nameCounts.reserveCapacity(applications.count)
for application in applications { nameCounts[application.name, default: 0] += 1 }
var groups: [String: [(url: URL, folder: URL)]] = [:]
for application in applications where (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) {
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
results = history.suggestions(in: applications)
} else {
results = history.results(for: searchField.stringValue, in: applications)
}
tableView.reloadData()
if !results.isEmpty {
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row)
}
emptyLabel.isHidden = !results.isEmpty
if discoveryTask != nil {
emptyLabel.stringValue = "Finding applications…"
statusLabel.stringValue = "Finding applications…"
} else {
emptyLabel.stringValue = applications.isEmpty ? "No applications found" : "No matching applications"
statusLabel.stringValue = results.count == 1 ? "1 application" : "\(results.count) applications"
}
updateOpenButton()
}
func controlTextDidChange(_ notification: Notification) {
updateResults()
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
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 func moveSelection(by offset: Int) {
guard !results.isEmpty else { return }
let row = min(max(tableView.selectedRow + offset, 0), results.count - 1)
tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
tableView.scrollRowToVisible(row)
}
private func updateOpenButton() {
openButton.isEnabled = !isLaunching && launcherFrameBeforeReview == nil && results.indices.contains(tableView.selectedRow)
actionsButton.isEnabled = openButton.isEnabled
}
func tableViewSelectionDidChange(_ notification: Notification) {
updateOpenButton()
}
func numberOfRows(in tableView: NSTableView) -> Int {
results.count
}
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)
let application = results[row]
cell.textField?.stringValue = application.name
let location = locationLabels[application.id]
cell.locationLabel.stringValue = location ?? ""
cell.locationLabel.isHidden = location == nil
cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path)
cell.toolTip = application.url.path
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
return view
}
private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell {
let cell = ApplicationCell()
cell.identifier = identifier
let icon = NSImageView()
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
label.textColor = SunsetPalette.text
label.lineBreakMode = .byTruncatingTail
let location = cell.locationLabel
location.font = .systemFont(ofSize: 12)
location.textColor = SunsetPalette.secondaryText
location.lineBreakMode = .byTruncatingMiddle
location.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
location.isHidden = true
let title = NSStackView(views: [label, location])
title.orientation = .horizontal
title.alignment = .centerY
title.spacing = 8
let kind = NSTextField(labelWithString: "Application")
kind.font = .systemFont(ofSize: 14)
kind.textColor = SunsetPalette.secondaryText
kind.setContentCompressionResistancePriority(.required, for: .horizontal)
kind.setAccessibilityElement(false)
cell.imageView = icon
cell.textField = label
for view in [icon, title, kind] {
view.translatesAutoresizingMaskIntoConstraints = false
cell.addSubview(view)
}
NSLayoutConstraint.activate([
icon.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
icon.widthAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
icon.heightAnchor.constraint(equalToConstant: LauncherRowView.iconSize),
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
title.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
title.trailingAnchor.constraint(lessThanOrEqualTo: kind.leadingAnchor, constant: -12),
kind.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
kind.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
])
return cell
}
@objc private func showActions() {
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow]
actions.open = { [weak self] in self?.openApplication(application) }
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) }
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)
}
}
}
@objc private func openSelection() {
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 else { return }
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()
}
}
}
@MainActor
private final class ApplicationCell: NSTableCellView {
let locationLabel = NSTextField(labelWithString: "")
}
@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
private final class LauncherPanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
var navigateSelection: ((Int) -> Void)?
var dismiss: (() -> Void)?
var dragEnded: (() -> Void)?
override func sendEvent(_ event: NSEvent) {
if event.type == .keyDown, attachedSheet == nil,
event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control {
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 iconSize: CGFloat = 22
static let iconToTextSpacing: CGFloat = 12
override func drawSelection(in dirtyRect: NSRect) {
let selection = NSBezierPath(roundedRect: bounds.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
}