Files
conjure/Sources/Conjure/LauncherWindowController.swift
T

431 lines
18 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 var applications: [Application] = []
private var results: [Application] = []
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
init() {
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.isReleasedWhenClosed = false
window.center()
super.init(window: window)
configureContent(in: window)
window.delegate = self
window.navigateSelection = { [weak self] offset in self?.moveSelection(by: offset) }
}
required init?(coder: NSCoder) {
fatalError("Conjure creates its window programmatically")
}
func present() {
searchField.stringValue = ""
updateResults()
window?.deminiaturize(nil)
showWindow(nil)
NSApp.activate()
window?.makeKeyAndOrderFront(nil)
window?.makeFirstResponder(searchField)
(searchField.currentEditor() as? NSTextView)?.insertionPointColor = .labelColor
refreshApplications()
}
func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return }
window?.orderOut(nil)
}
private func configureContent(in window: NSWindow) {
let content = NSVisualEffectView()
content.material = .hudWindow
content.blendingMode = .behindWindow
content.state = .active
content.wantsLayer = true
content.layer?.cornerRadius = 24
content.layer?.masksToBounds = true
content.layer?.borderWidth = 0.5
window.contentView = content
let tint = NSView(frame: content.bounds)
tint.autoresizingMask = [.width, .height]
tint.wantsLayer = true
content.addSubview(tint)
searchField.placeholderString = "Search for apps…"
searchField.font = .systemFont(ofSize: 20)
searchField.textColor = .labelColor
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 = .tertiaryLabelColor
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 = .secondaryLabelColor
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?.window?.orderOut(nil) }
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 = .secondaryLabelColor
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
emptyLabel.textColor = .secondaryLabelColor
emptyLabel.alignment = .center
openButton.isBordered = false
openButton.font = .systemFont(ofSize: 14, weight: .medium)
openButton.contentTintColor = .labelColor
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 = .secondaryLabelColor
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
actionPill.wantsLayer = true
actionPill.layer?.cornerRadius = 18
actionPill.layer?.masksToBounds = true
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 = NSColor.separatorColor.cgColor
content.layer?.borderColor = border
returnKey.layer?.borderColor = border
actionPill.layer?.borderColor = border
tint.layer?.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.85).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.discoveryTask = nil
self.updateResults()
}
}
private func updateResults() {
results = ApplicationSearch.results(for: searchField.stringValue, in: applications)
tableView.reloadData()
if !results.isEmpty {
tableView.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
tableView.scrollRowToVisible(0)
}
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(_:)):
window?.orderOut(nil)
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? NSTableCellView)
?? makeCell(identifier: identifier)
let application = results[row]
cell.textField?.stringValue = application.name
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) -> NSTableCellView {
let cell = NSTableCellView()
cell.identifier = identifier
let icon = NSImageView()
icon.imageScaling = .scaleProportionallyDown
icon.setAccessibilityElement(false)
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: 14, weight: .medium)
label.lineBreakMode = .byTruncatingTail
let kind = NSTextField(labelWithString: "Application")
kind.font = .systemFont(ofSize: 14)
kind.textColor = .secondaryLabelColor
kind.setContentCompressionResistancePriority(.required, for: .horizontal)
kind.setAccessibilityElement(false)
cell.imageView = icon
cell.textField = label
for view in [icon, label, 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),
label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
label.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
label.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)
window?.orderOut(nil)
} 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 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)?
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)
}
override func performClose(_ sender: Any?) {
close()
}
}
@MainActor
private final class LauncherRowView: NSTableRowView {
override func drawSelection(in dirtyRect: NSRect) {
let selection = NSBezierPath(roundedRect: bounds, xRadius: 10, yRadius: 10)
NSColor.unemphasizedSelectedContentBackgroundColor.withAlphaComponent(0.55).setFill()
selection.fill()
if NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast {
NSColor.labelColor.setStroke()
selection.lineWidth = 1
selection.stroke()
}
}
}