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? private var isLaunching = false 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.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 = SunsetPalette.accent 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 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?.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 = 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(_:)): 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? 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) 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 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)? 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.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 }