Files
conjure/Sources/Conjure/LauncherWindowController.swift
T

546 lines
23 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?
init(history: LaunchHistory = LaunchHistory()) {
self.history = history
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 self?.moveSelection(by: offset) }
window.dismiss = { [weak self] in self?.dismiss() }
window.dragEnded = { [weak self] in self?.alignmentGuides?.end() }
}
required init?(coder: NSCoder) {
fatalError("Conjure creates its window programmatically")
}
func present() {
let frontmost = NSWorkspace.shared.frontmostApplication
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
previousApplication = frontmost
}
searchField.stringValue = ""
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 }
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: [])
}
}
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, 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 = 38
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: 14, weight: .medium)
openButton.contentTintColor = SunsetPalette.text
openButton.keyEquivalent = "\r"
openButton.target = self
openButton.action = #selector(openSelection)
openButton.isEnabled = false
openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select."
let returnKey = NSTextField(labelWithString: "↵")
returnKey.font = .systemFont(ofSize: 14)
returnKey.textColor = SunsetPalette.secondaryText
returnKey.alignment = .center
returnKey.wantsLayer = true
returnKey.layer?.cornerRadius = 5
returnKey.layer?.borderWidth = 1
returnKey.setAccessibilityElement(false)
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] {
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),
returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12),
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
}
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 }
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 = ApplicationSearch.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 && results.indices.contains(tableView.selectedRow)
}
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: 14, 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: 22),
icon.heightAnchor.constraint(equalToConstant: 22),
title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
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 openSelection() {
guard !isLaunching, results.indices.contains(tableView.selectedRow) else { return }
let application = results[tableView.selectedRow]
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 == .leftMouseDown || event.type == .leftMouseUp {
dragEnded?()
}
}
override func performClose(_ sender: Any?) {
dismiss?()
}
}
@MainActor
private final class LauncherRowView: NSTableRowView {
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
private 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
}