Files
conjure/Sources/Conjure/LauncherWindowController.swift
T

281 lines
12 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 = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Conjure"
window.contentMinSize = NSSize(width: 420, height: 300)
window.isReleasedWhenClosed = false
window.center()
super.init(window: window)
configureContent(in: window)
window.delegate = self
}
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)
refreshApplications()
}
func windowDidResignKey(_ notification: Notification) {
guard !isLaunching, window?.attachedSheet == nil else { return }
window?.orderOut(nil)
}
private func configureContent(in window: NSWindow) {
guard let content = window.contentView else { return }
searchField.placeholderString = "Search applications"
searchField.controlSize = .large
searchField.font = .systemFont(ofSize: NSFont.systemFontSize(for: .large))
searchField.sendsSearchStringImmediately = true
searchField.sendsWholeSearchString = false
searchField.delegate = self
searchField.setAccessibilityLabel("Search applications")
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("application"))
column.resizingMask = .autoresizingMask
tableView.addTableColumn(column)
tableView.headerView = nil
tableView.style = .inset
tableView.rowHeight = 44
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.borderType = .noBorder
statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
statusLabel.textColor = .secondaryLabelColor
statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
emptyLabel.textColor = .secondaryLabelColor
emptyLabel.alignment = .center
openButton.bezelStyle = .rounded
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 footer = NSStackView(views: [statusLabel, NSView(), openButton])
footer.orientation = .horizontal
footer.spacing = 12
for view in [searchField, scrollView, emptyLabel, footer] {
view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(view)
}
NSLayoutConstraint.activate([
searchField.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
searchField.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
searchField.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
scrollView.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 12),
scrollView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
scrollView.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -12),
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: -16),
footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12),
])
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
}
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.lineBreakMode = .byTruncatingTail
cell.imageView = icon
cell.textField = label
for view in [icon, label] {
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: 28),
icon.heightAnchor.constraint(equalToConstant: 28),
label.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
label.centerYAnchor.constraint(equalTo: cell.centerYAnchor),
label.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)
}
}
}
}