745 lines
33 KiB
Swift
745 lines
33 KiB
Swift
import AppKit
|
||
import ConjureKit
|
||
import CoreText
|
||
|
||
@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 = shortcutBadge("↵")
|
||
let commandKey = shortcutBadge("⌘")
|
||
let kKey = shortcutBadge("K")
|
||
for badge in [returnKey, commandKey, kKey] {
|
||
badge.font = openButton.font
|
||
}
|
||
|
||
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, commandKey, kKey] {
|
||
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),
|
||
commandKey.leadingAnchor.constraint(equalTo: actionsButton.trailingAnchor, constant: 8),
|
||
commandKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
||
kKey.leadingAnchor.constraint(equalTo: commandKey.trailingAnchor, constant: 2),
|
||
kKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
||
kKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
|
||
actionsButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
||
returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor),
|
||
returnKey.widthAnchor.constraint(equalToConstant: 18),
|
||
returnKey.heightAnchor.constraint(equalToConstant: 18),
|
||
])
|
||
window.effectiveAppearance.performAsCurrentDrawingAppearance {
|
||
let border = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||
content.layer?.borderColor = border
|
||
returnKey.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
|
||
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.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 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
|
||
}
|
||
|
||
@MainActor
|
||
func shortcutBadge(_ title: String) -> NSTextField {
|
||
let badge = ShortcutBadge(labelWithString: title)
|
||
badge.font = .systemFont(ofSize: 14)
|
||
badge.textColor = SunsetPalette.secondaryText
|
||
badge.alignment = .center
|
||
badge.wantsLayer = true
|
||
badge.layer?.cornerRadius = 5
|
||
badge.layer?.borderWidth = 1
|
||
badge.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor
|
||
badge.setAccessibilityElement(false)
|
||
badge.translatesAutoresizingMaskIntoConstraints = false
|
||
NSLayoutConstraint.activate([
|
||
badge.widthAnchor.constraint(equalToConstant: 18),
|
||
badge.heightAnchor.constraint(equalToConstant: 18)
|
||
])
|
||
return badge
|
||
}
|
||
|
||
@MainActor
|
||
private final class ShortcutBadge: NSTextField {
|
||
// NSTextField's horizontal alignment insets otherwise make an 18-point badge 22 points wide.
|
||
override var alignmentRectInsets: NSEdgeInsets { NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) }
|
||
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||
let line = CTLineCreateWithAttributedString(attributedStringValue)
|
||
let ink = CTLineGetBoundsWithOptions(line, .useGlyphPathBounds)
|
||
context.saveGState()
|
||
context.textMatrix = .identity
|
||
// Center visible ink, not the font's asymmetric ascender/descender line box.
|
||
context.translateBy(x: bounds.midX - ink.midX, y: bounds.midY + ink.midY)
|
||
context.scaleBy(x: 1, y: -1)
|
||
context.textPosition = .zero
|
||
CTLineDraw(line, context)
|
||
context.restoreGState()
|
||
}
|
||
}
|