import AppKit import ConjureKit import CoreText import ApplicationServices @MainActor final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { private let searchField = NSSearchField() private let searchSymbol = IconView(font: .systemFont(ofSize: 20)) private let commandBack = NSButton(title: "", target: nil, action: nil) private let translationHeader = NSStackView() private let tableView = ApplicationTableView() private let statusLabel = NSTextField(labelWithString: "Finding applications…") private let emptyLabel = NSTextField(labelWithString: "Finding applications…") private let sectionLabel = NSTextField(labelWithString: "Applications") private let openButton = NSButton(title: "Open Application", target: nil, action: nil) private let history: LaunchHistory private let defaults: UserDefaults private let pasteboard: NSPasteboard private var applications: [Application] = Application.commands private var results: [Application] = [] private var calculation: CalculatorResult? private var isCurrencyCalculation = false private let currency = CurrencyCalculation() private var locationLabels: [URL: String] = [:] private var discoveryTask: Task? private var isLaunching = false private var previousApplication: NSRunningApplication? private var alignmentGuides: LauncherAlignmentGuides? private static let positionKey = "launcherPosition" private static let notesSizeKey = "notesWindowSize" 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? private var translation: TranslationController? private var emojiPicker: EmojiPickerController? private var notes: NotesController? private let notesDirectory: URL? private let toggleSystemTheme: @MainActor () async throws -> Void private var isCommandOpen: Bool { activeCommand != nil } private var activeCommand: Application.Command? private var pendingPresentation: (() -> Void)? private static let emojiIcon = makeEmojiIcon("🤔") private static let notesIcon = makeEmojiIcon("📔") private static let translateIcon = makeEmojiIcon("🌏") private static let themeIcon = makeEmojiIcon("🌗") private static func makeEmojiIcon(_ emoji: String) -> NSImage { let text = NSAttributedString(string: emoji, attributes: [.font: NSFont.systemFont(ofSize: 28)]) let line = CTLineCreateWithAttributedString(text) return NSImage(size: NSSize(width: 32, height: 32), flipped: false) { rect in guard let context = NSGraphicsContext.current?.cgContext else { return false } context.textPosition = .zero let bounds = CTLineGetImageBounds(line, context) context.textPosition = CGPoint(x: rect.midX - bounds.midX, y: rect.midY - bounds.midY) CTLineDraw(line, context) return true } } init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller(), defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general, notesDirectory: URL? = nil, toggleSystemTheme: @escaping @MainActor () async throws -> Void = { try await SystemTheme.toggle() }) { self.history = history self.uninstaller = uninstaller self.defaults = defaults self.pasteboard = pasteboard self.notesDirectory = notesDirectory self.toggleSystemTheme = toggleSystemTheme let window = LauncherWindow( contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), styleMask: [.borderless], backing: .buffered, defer: false ) window.title = "Conjure" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.animationBehavior = .none 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 if let picker = self.emojiPicker { picker.moveSelection(by: offset) } else { self.moveSelection(by: offset) } } window.commandNavigation = { [weak self] key in self?.notes?.handleControlKey(key) ?? self?.translation?.handleControlKey(key) ?? self?.emojiPicker?.handleControlKey(key) ?? false } window.commandShortcut = { [weak self] key in self?.notes?.handleCommandKey(key) ?? false } window.usesTextNavigation = { [weak self] in self?.notes != nil } window.dismiss = { [weak self] in self?.dismiss() } window.escape = { [weak self] in self?.handleEscape() } window.canUseNumberShortcuts = { [weak self] in guard let self else { return false } return !self.isLaunching && self.calculation == nil && self.resultCount > 0 && self.launcherFrameBeforeReview == nil && !self.actions.isShown && !self.isCommandOpen } window.numberHintsChanged = { [weak self] in self?.updateNumberHints() } window.launchNumber = { [weak self] index in guard let self else { return } if self.results.indices.contains(index) { self.openApplication(self.results[index]) } } 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" self.window?.makeFirstResponder(self.searchField) } currency.changed = { [weak self] in self?.updateResults(preservingSelection: true) } restorePosition() } required init?(coder: NSCoder) { fatalError("Conjure creates its window programmatically") } func present(command: Application.Command? = nil) { guard !isLaunching, window?.attachedSheet == nil else { return } if let command, !command.presentsView { guard launcherFrameBeforeReview == nil else { return } executeCommand(command) return } if launcherFrameBeforeReview != nil { pendingPresentation = { [weak self] in self?.present(command: command) } uninstallReview?.goBack() return } guard closeCommand() else { return } actions.close(animated: false) (window as? LauncherWindow)?.beginNumberHintSession() let frontmost = NSWorkspace.shared.frontmostApplication if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier { previousApplication = frontmost } searchField.stringValue = "" restorePosition() updateResults() if let command { executeCommand(command) // Validate the final size; compact Notes may fit where the launcher did not. if command == .notes { restorePosition() } } window?.deminiaturize(nil) showWindow(nil) NSApp.activate() window?.makeKeyAndOrderFront(nil) if let notes { notes.focusEditor() } else { window?.makeFirstResponder(searchField) } (window as? LauncherWindow)?.sampleNumberHints() (searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent refreshApplications() } func toggle(command: Application.Command? = nil) { guard !isLaunching, window?.attachedSheet == nil else { return } if window?.isVisible == true, window?.isKeyWindow == true, launcherFrameBeforeReview == nil, activeCommand == command { dismiss() } else { present(command: command) } } func dismiss(restoringFocus: Bool = true) { guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return } if launcherFrameBeforeReview != nil { guard uninstallReview?.canClose != false else { return } pendingPresentation = { [weak self] in self?.dismiss(restoringFocus: restoringFocus) } uninstallReview?.goBack() return } guard closeCommand() else { return } actions.close(animated: false) 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 } defaults.set([ "display": identifier, "centerX": window.frame.midX, "centerY": window.frame.midY ], forKey: Self.positionKey) } private func restorePosition() { guard let window else { return } let saved = defaults.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 center: CGPoint? if savedScreen != nil, let x = saved?["centerX"] as? Double, let y = saved?["centerY"] as? Double { center = CGPoint(x: x, y: y) } let frame = LauncherAlignment.restoredFrame( savedCenter: center, 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 windowDidResize(_ notification: Notification) { guard notes != nil, let window, window.styleMask.contains(.resizable) else { return } defaults.set(NSStringFromSize(window.frame.size), forKey: Self.notesSizeKey) } func windowShouldClose(_ sender: NSWindow) -> Bool { dismiss() return false } func windowDidResignKey(_ notification: Notification) { (window as? LauncherWindow)?.resetNumberHints() 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 apps, calculate, or convert…", attributes: [.foregroundColor: ThemePalette.secondaryText, .font: searchFont] ) searchField.textColor = ThemePalette.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, calculate, or convert units and currencies") searchSymbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) searchSymbol.contentTintColor = ThemePalette.accent commandBack.image = IconStyle.symbol("chevron.left", for: searchFont) commandBack.imageScaling = .scaleProportionallyDown commandBack.isBordered = false commandBack.contentTintColor = ThemePalette.secondaryText commandBack.setAccessibilityLabel("Back to applications") commandBack.toolTip = "Back to applications" commandBack.isHidden = true NSLayoutConstraint.activate([ commandBack.widthAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), commandBack.heightAnchor.constraint(equalToConstant: IconStyle.applicationSize(for: searchFont)), ]) translationHeader.isHidden = true let header = NSStackView(views: [searchSymbol, commandBack, searchField, translationHeader]) header.orientation = .horizontal header.alignment = .centerY header.spacing = 12 sectionLabel.font = .systemFont(ofSize: 12, weight: .semibold) sectionLabel.textColor = ThemePalette.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 = LauncherScrollView() 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 scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 0, bottom: 16, right: 0) statusLabel.font = .systemFont(ofSize: 12) statusLabel.textColor = ThemePalette.secondaryText statusLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) emptyLabel.textColor = ThemePalette.secondaryText emptyLabel.alignment = .center openButton.target = self openButton.action = #selector(openSelection) openButton.isEnabled = false actionsButton.target = self actionsButton.action = #selector(showActions) actionsButton.setAccessibilityLabel("Entry actions") actionsButton.toolTip = "Actions for the selected application or command (Command-K)" actionsButton.isEnabled = false openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select." let actionPill = ActionPill(primaryButton: openButton, actionsButton: actionsButton) let footer = NSStackView(views: [statusLabel, NSView(), actionPill]) footer.orientation = .horizontal footer.alignment = .centerY footer.spacing = 12 for view in [scrollView, header, sectionLabel, 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: footer.trailingAnchor), header.heightAnchor.constraint(equalToConstant: 36), sectionLabel.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 16), sectionLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), scrollView.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: -10), 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), ]) window.effectiveAppearance.performAsCurrentDrawingAppearance { let border = ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor content.layer?.borderColor = border } 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 + Application.commands self.updateLocationLabels() self.discoveryTask = nil self.updateResults(preservingSelection: true) } } private func updateLocationLabels() { var nameCounts: [String: Int] = [:] nameCounts.reserveCapacity(applications.count) for application in applications where application.command == nil { nameCounts[application.name, default: 0] += 1 } var groups: [String: [(url: URL, folder: URL)]] = [:] for application in applications where application.command == nil && (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) { guard !isCommandOpen else { return } let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow) ? results[tableView.selectedRow].id : nil let query = searchField.stringValue currency.update(query) isCurrencyCalculation = currency.isCurrency calculation = currency.result if calculation != nil { (window as? LauncherWindow)?.resetNumberHints() } if calculation != nil { results = [] } else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { results = history.suggestions(in: applications) } else { results = history.results(for: searchField.stringValue, in: applications) } sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates" : calculation == nil ? "Applications & Commands" : "Calculator" tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result" : calculation == nil ? "Applications and commands" : "Calculator result") tableView.reloadData() if resultCount > 0 { let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0 tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) tableView.scrollRowToVisible(row) } emptyLabel.isHidden = resultCount > 0 if let calculation { statusLabel.stringValue = calculation.detail } else if discoveryTask != nil { emptyLabel.stringValue = "Finding applications…" statusLabel.stringValue = "Finding applications…" } else { emptyLabel.stringValue = "No matching applications or commands" let commandCount = results.lazy.filter { $0.command != nil }.count statusLabel.stringValue = "\(results.count - commandCount) applications · \(commandCount) commands" } updateOpenButton() (window as? LauncherWindow)?.sampleNumberHints() } func controlTextDidChange(_ notification: Notification) { if let translation { translation.inputChanged(); return } if let emojiPicker { emojiPicker.inputChanged(); return } updateResults() } private func handleEscape() { let selector = #selector(NSResponder.cancelOperation(_:)) if actions.handle(selector) || notes?.handle(selector) == true || translation?.handle(selector) == true || emojiPicker?.handle(selector) == true { return } dismiss() } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.cancelOperation(_:)) { handleEscape() return true } if let notes { return notes.handle(commandSelector) } if let emojiPicker { return emojiPicker.handle(commandSelector) } if let translation { return translation.handle(commandSelector) } switch commandSelector { case #selector(NSResponder.moveDown(_:)): moveSelection(by: 1) case #selector(NSResponder.moveUp(_:)): moveSelection(by: -1) case #selector(NSResponder.insertNewline(_:)): openSelection() default: return false } return true } private var resultCount: Int { calculation == nil ? results.count : 1 } private func moveSelection(by offset: Int) { guard !isCommandOpen else { return } guard resultCount > 0 else { return } let row = min(max(tableView.selectedRow + offset, 0), resultCount - 1) tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) tableView.scrollRowToVisible(row) } private func updateOpenButton() { let canAct = !isLaunching && launcherFrameBeforeReview == nil && !isCommandOpen && tableView.selectedRow >= 0 openButton.title = calculation == nil ? (results.indices.contains(tableView.selectedRow) && results[tableView.selectedRow].command != nil ? "Open Command" : "Open Application") : "Copy Answer" openButton.toolTip = calculation == nil ? "Open the selected entry (Return). Use ↑ and ↓ to select." : "Copy the answer to the clipboard (Return)." openButton.isEnabled = canAct && (calculation.map { $0.copyValue != nil } ?? results.indices.contains(tableView.selectedRow)) actionsButton.isEnabled = canAct && calculation == nil && results.indices.contains(tableView.selectedRow) } func tableViewSelectionDidChange(_ notification: Notification) { updateOpenButton() } func numberOfRows(in tableView: NSTableView) -> Int { resultCount } private func spacingBeforeRow(_ row: Int) -> CGFloat { guard calculation == nil, searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, row > 0, results.indices.contains(row), history.isFavorite(results[row - 1]), !history.isFavorite(results[row]) else { return 0 } return 12 } func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { LauncherRowView.rowHeight + spacingBeforeRow(row) } 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) if let calculation { cell.textField?.stringValue = calculation.displayValue cell.textField?.setAccessibilityLabel(nil) cell.locationLabel.isHidden = true cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates" : calculation.copyValue == nil ? "Error" : "Calculator" cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil) cell.imageView?.contentTintColor = ThemePalette.accent cell.toolTip = calculation.detail } else if results[row].command != nil { let entry = results[row] cell.textField?.stringValue = entry.name cell.textField?.setAccessibilityLabel(history.isFavorite(entry) ? "\(entry.name), Favorite" : nil) cell.locationLabel.isHidden = true cell.kindLabel.attributedStringValue = history.isFavorite(entry) ? ApplicationCell.favoriteCommandKind : ApplicationCell.commandKind switch entry.command { case .emoji: cell.imageView?.image = Self.emojiIcon cell.toolTip = "Search and copy emoji" case .notes: cell.imageView?.image = Self.notesIcon cell.toolTip = "Write notes with Markdown and images" case .toggleTheme: cell.imageView?.image = Self.themeIcon cell.toolTip = "Switch macOS between light and dark appearance" default: cell.imageView?.image = Self.translateIcon cell.toolTip = "Translate English to Japanese" } cell.imageView?.contentTintColor = nil } else { let application = results[row] cell.textField?.stringValue = application.name let location = locationLabels[application.id] cell.locationLabel.stringValue = location ?? "" cell.locationLabel.isHidden = location == nil let isFavorite = history.isFavorite(application) cell.kindLabel.attributedStringValue = isFavorite ? ApplicationCell.favoriteKind : ApplicationCell.applicationKind cell.textField?.setAccessibilityLabel(isFavorite ? "\(application.name), Favorite" : nil) cell.imageView?.contentTintColor = nil cell.imageView?.image = NSWorkspace.shared.icon(forFile: application.url.path) cell.toolTip = application.url.path } configureNumberHint(cell, row: row) 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 view.topSpacing = spacingBeforeRow(row) return view } private func configureNumberHint(_ cell: ApplicationCell, row: Int, animated: Bool = false) { let visible = (window as? LauncherWindow)?.numberHintsVisible == true && calculation == nil && row < 9 && row < resultCount && !isCommandOpen cell.setNumberHint(visible ? String(row + 1) : nil, animated: animated) cell.setAccessibilityHelp(visible ? "Command-\(row + 1) opens this entry" : nil) } private func updateNumberHints() { let rows = tableView.rows(in: tableView.visibleRect) guard rows.length > 0 else { return } for row in rows.location..<(rows.location + rows.length) { if let cell = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? ApplicationCell { configureNumberHint(cell, row: row, animated: true) } } } private func makeCell(identifier: NSUserInterfaceItemIdentifier) -> ApplicationCell { let cell = ApplicationCell() cell.identifier = identifier return cell } @objc private func showActions() { guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return } guard !isCommandOpen else { return } (window as? LauncherWindow)?.resetNumberHints() let application = results[tableView.selectedRow] actions.open = { [weak self] in self?.openApplication(application) } actions.isFavorite = history.isFavorite(application) actions.toggleFavorite = { [weak self] in guard let self else { return } self.history.toggleFavorite(application) self.updateResults(preservingSelection: true) } actions.showInFinder = { [weak self] in self?.dismiss(restoringFocus: false) NSWorkspace.shared.activateFileViewerSelecting([application.url]) } actions.resetRanking = { [weak self] in guard let self else { return } self.history.resetRanking(of: application) self.updateResults(preservingSelection: true) } actions.quit = { [weak self] in self?.quitApplication(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) } (window as? LauncherWindow)?.resetNumberHints() 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) let pending = self.pendingPresentation self.pendingPresentation = nil pending?() } } } @objc private func openSelection() { guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return } if let calculation { guard tableView.selectedRow == 0, let value = calculation.copyValue else { return } pasteboard.clearContents() if pasteboard.setString(value, forType: .string) { dismiss() } return } guard results.indices.contains(tableView.selectedRow) else { return } openApplication(results[tableView.selectedRow]) } private func quitApplication(_ application: Application) { do { try AppLauncher.quit(application.url) dismiss() } catch { let alert = NSAlert(error: error) alert.messageText = "Couldn’t quit \(application.name)" if let window { alert.beginSheetModal(for: window) } } } private func openApplication(_ application: Application) { guard !isLaunching, !isCommandOpen, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return } if let command = application.command { executeCommand(command, dismissOnSuccess: true) return } (window as? LauncherWindow)?.resetNumberHints() 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() } } private func pasteEmoji() { let target = previousApplication let changeCount = pasteboard.changeCount dismiss() guard let target, !target.isTerminated, target.processIdentifier != ProcessInfo.processInfo.processIdentifier, AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) else { return } Task { [weak self] in // Wait for focus restoration; never paste into a different app or a reopened launcher. for _ in 0..<20 { try? await Task.sleep(for: .milliseconds(25)) guard let self, self.window?.isVisible == false, !target.isTerminated, self.pasteboard.changeCount == changeCount else { return } guard NSWorkspace.shared.frontmostApplication?.processIdentifier == target.processIdentifier else { continue } guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: true), let up = CGEvent(keyboardEventSource: nil, virtualKey: 9, keyDown: false) else { return } down.flags = .maskCommand up.flags = .maskCommand down.postToPid(target.processIdentifier) up.postToPid(target.processIdentifier) return } } } @objc private func goBackCommand() { translation?.goBack() emojiPicker?.goBack() notes?.goBack() } func saveNotes() -> Bool { notes?.save() ?? true } @discardableResult private func closeCommand() -> Bool { guard notes?.close() != false else { return false } translation?.close() emojiPicker?.close() return true } private func runAction(_ command: Application.Command, dismissOnSuccess: Bool) { let action: @MainActor () async throws -> Void switch command { case .toggleTheme: action = toggleSystemTheme case .translate, .emoji, .notes: preconditionFailure("View commands must be presented, not run as actions") } if dismissOnSuccess { actions.close(animated: false) } isLaunching = true updateOpenButton() Task { do { try await action() history.recordLaunch(of: Application(command: command)) isLaunching = false if dismissOnSuccess { dismiss() } } catch { let alert = NSAlert(error: error) alert.messageText = "Couldn’t run \(Application(command: command).name)" if let window, window.isVisible { await alert.beginSheetModal(for: window) } else { alert.runModal() } } isLaunching = false updateOpenButton() } } private func executeCommand(_ command: Application.Command, dismissOnSuccess: Bool = false) { guard command.presentsView else { runAction(command, dismissOnSuccess: dismissOnSuccess) return } guard !isCommandOpen, let content = window?.contentView else { return } let controller: NSViewController let placeholder: String let accessibilityLabel: String switch command { case .translate: let tool = TranslationController(defaults: defaults, pasteboard: pasteboard) tool.dismiss = { [weak self] in self?.dismiss() } translation = tool tool.input = { [weak self] in self?.searchField.stringValue ?? "" } tool.replaceInput = { [weak self] in self?.searchField.stringValue = $0 } tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) } tool.inputPresentationChanged = { [weak self, weak tool] in guard let self, let tool else { return } let attributes = self.searchField.placeholderAttributedString?.attributes(at: 0, effectiveRange: nil) ?? [:] self.searchField.placeholderAttributedString = NSAttributedString(string: tool.inputPlaceholder, attributes: attributes) self.searchField.setAccessibilityLabel(tool.inputAccessibilityLabel) self.commandBack.setAccessibilityLabel(tool.backAccessibilityLabel) self.commandBack.toolTip = tool.backAccessibilityLabel } controller = tool placeholder = tool.inputPlaceholder accessibilityLabel = tool.inputAccessibilityLabel case .emoji: let tool = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) emojiPicker = tool tool.input = { [weak self] in self?.searchField.stringValue ?? "" } tool.focusSearch = { [weak self] in self?.window?.makeFirstResponder(self?.searchField) } tool.copied = { [weak self] in self?.dismiss() } tool.pasteRequested = { [weak self] in self?.pasteEmoji() } controller = tool placeholder = "Search emoji by name or :shortcode:…" accessibilityLabel = "Search emoji" case .notes: let tool = NotesController(defaults: defaults, directory: notesDirectory) notes = tool controller = tool placeholder = "Conjure Notes" accessibilityLabel = "Conjure Notes" case .toggleTheme: preconditionFailure("Action commands must run without presenting a view") } activeCommand = command history.recordLaunch(of: Application(command: command)) let previousQuery = searchField.stringValue let previousFrame = window?.frame let previousPlaceholder = searchField.placeholderAttributedString let close: () -> Void = { [weak self, weak controller] in guard let self, let controller else { return } controller.view.removeFromSuperview() self.translation?.providerControl.removeFromSuperview() self.translationHeader.isHidden = true self.translation = nil self.emojiPicker = nil self.notes = nil self.searchField.isHidden = false self.launcherViews.first?.isHidden = false self.launcherViews.first?.setAccessibilityHidden(false) if command == .notes, let previousFrame, let window = self.window { let frame = LauncherAlignment.restoredFrame( savedCenter: CGPoint(x: window.frame.midX, y: window.frame.midY), windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame) window.styleMask = [.borderless] window.minSize = .zero window.appearance = NSAppearance(named: .darkAqua) window.setFrame(frame, display: true) if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) } } self.activeCommand = nil self.commandBack.isHidden = true self.commandBack.target = nil self.searchSymbol.isHidden = false for view in self.launcherViews.dropFirst() { view.isHidden = false view.setAccessibilityHidden(false) view.alphaValue = 1 } self.searchField.placeholderAttributedString = previousPlaceholder self.searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies") self.searchField.stringValue = previousQuery self.openButton.keyEquivalent = "\r" self.updateResults() self.window?.makeFirstResponder(self.searchField) } translation?.closed = close emojiPicker?.closed = close notes?.closed = close actions.close(animated: false) (window as? LauncherWindow)?.resetNumberHints() openButton.keyEquivalent = "" searchSymbol.isHidden = true commandBack.setAccessibilityLabel("Back to applications") commandBack.toolTip = "Back to applications" commandBack.target = self commandBack.action = #selector(goBackCommand) commandBack.isHidden = false searchField.stringValue = "" searchField.placeholderAttributedString = NSAttributedString( string: placeholder, attributes: previousPlaceholder?.attributes(at: 0, effectiveRange: nil) ?? [:] ) searchField.setAccessibilityLabel(accessibilityLabel) if command == .notes { let savedSize = defaults.string(forKey: Self.notesSizeKey).map(NSSizeFromString) ?? NSSize(width: 570, height: 404) window?.appearance = nil window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView] window?.minSize = NSSize(width: 570, height: 260) window?.standardWindowButton(.miniaturizeButton)?.isHidden = true window?.standardWindowButton(.zoomButton)?.isHidden = true launcherViews.first?.isHidden = true launcherViews.first?.setAccessibilityHidden(true) if let previousFrame, let window { let visibleFrame = window.screen?.visibleFrame ?? previousFrame let size = NSSize(width: min(max(savedSize.width, 570), visibleFrame.width), height: min(max(savedSize.height, 260), visibleFrame.height)) let frame = LauncherAlignment.restoredFrame( savedCenter: CGPoint(x: previousFrame.midX, y: previousFrame.midY), windowSize: size, visibleFrame: visibleFrame) window.setFrame(frame, display: true) } if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 14) } } for view in launcherViews.dropFirst() { view.isHidden = true view.setAccessibilityHidden(true) } controller.view.translatesAutoresizingMaskIntoConstraints = false content.addSubview(controller.view) if let translation { translationHeader.addArrangedSubview(translation.providerControl) translationHeader.isHidden = false } NSLayoutConstraint.activate([ controller.view.leadingAnchor.constraint(equalTo: content.leadingAnchor), controller.view.trailingAnchor.constraint(equalTo: content.trailingAnchor), command == .notes ? controller.view.topAnchor.constraint(equalTo: content.topAnchor) : controller.view.topAnchor.constraint(equalTo: searchField.bottomAnchor, constant: 16), controller.view.bottomAnchor.constraint(equalTo: content.bottomAnchor), ]) if let notes { notes.focusEditor() } else { window?.makeFirstResponder(searchField) } } } @MainActor final class ApplicationCell: NSTableCellView { private static let hintOffset: CGFloat = 14 private static let opaqueMask = [NSColor.black.cgColor, NSColor.black.cgColor] private static let fadedMask = [NSColor.black.cgColor, NSColor.clear.cgColor] private static let timing = CAMediaTimingFunction(name: .easeOut) static let applicationKind = NSAttributedString(string: "Application", attributes: [ .font: NSFont.systemFont(ofSize: LauncherRowView.textSize), .foregroundColor: ThemePalette.secondaryText ]) static let commandKind = NSAttributedString(string: "Command", attributes: applicationKind.attributes(at: 0, effectiveRange: nil)) static let favoriteKind = favorited(applicationKind) static let favoriteCommandKind = favorited(commandKind) private static func favorited(_ kind: NSAttributedString) -> NSAttributedString { let font = kind.attribute(.font, at: 0, effectiveRange: nil) as! NSFont let attachment = NSTextAttachment() attachment.image = NSImage(systemSymbolName: "star.fill", accessibilityDescription: "Favorite")? .withSymbolConfiguration(IconStyle.symbolConfiguration(for: font) .applying(.init(scale: .small)) .applying(.init(paletteColors: [ThemePalette.secondaryText]))) let size = attachment.image?.size ?? .zero attachment.bounds = NSRect(x: 0, y: (font.capHeight - size.height) / 2, width: size.width, height: size.height) let title = NSMutableAttributedString(attachment: attachment) title.append(NSAttributedString(string: " ")) title.append(kind) return title } let locationLabel = NSTextField(labelWithString: "") let kindLabel = NSTextField(labelWithString: "Application") let numberHint = ShortcutIndicator( "1", backgroundColor: ThemePalette.keyBackground, fontSize: 10, keySize: 20 ) private let kindFade = CAGradientLayer() private var numberHintVisible = false private var hintGeneration = 0 override init(frame frameRect: NSRect) { super.init(frame: frameRect) let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium) let icon = IconView(font: font) let label = NSTextField(labelWithString: "") label.font = font label.textColor = ThemePalette.text label.lineBreakMode = .byTruncatingTail locationLabel.font = .systemFont(ofSize: 12) locationLabel.textColor = ThemePalette.secondaryText locationLabel.lineBreakMode = .byTruncatingMiddle locationLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) locationLabel.isHidden = true let title = NSStackView(views: [label, locationLabel]) title.orientation = .horizontal title.alignment = .centerY title.spacing = 8 kindLabel.font = .systemFont(ofSize: font.pointSize) kindLabel.textColor = ThemePalette.secondaryText kindLabel.setContentCompressionResistancePriority(.required, for: .horizontal) kindLabel.setAccessibilityElement(false) kindLabel.wantsLayer = true imageView = icon textField = label numberHint.wantsLayer = true numberHint.isHidden = true numberHint.layer?.opacity = 0 numberHint.layer?.transform = CATransform3DMakeTranslation(Self.hintOffset, 0, 0) kindFade.colors = Self.opaqueMask kindFade.transform = CATransform3DMakeTranslation(Self.hintOffset, 0, 0) // Added last so the badge overlays the label without changing the row's layout. for view in [icon, title, kindLabel, numberHint] { view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) } NSLayoutConstraint.activate([ icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), icon.centerYAnchor.constraint(equalTo: centerYAnchor), title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing), title.centerYAnchor.constraint(equalTo: centerYAnchor), title.trailingAnchor.constraint(lessThanOrEqualTo: kindLabel.leadingAnchor, constant: -12), kindLabel.centerYAnchor.constraint(equalTo: centerYAnchor), kindLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), numberHint.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), numberHint.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } required init?(coder: NSCoder) { fatalError("Conjure creates application cells programmatically") } override func layout() { super.layout() updateKindFadeGeometry() } func setNumberHint(_ key: String?, animated: Bool = true) { if let key { numberHint.setSingleKey(key) } let visible = key != nil let animate = animated && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion if animate && visible == numberHintVisible { return } numberHintVisible = visible hintGeneration += 1 let generation = hintGeneration if animate { layoutSubtreeIfNeeded() } updateKindFadeGeometry() guard let hintLayer = numberHint.layer, let labelLayer = kindLabel.layer else { return } let hintPresentation = hintLayer.presentation() ?? hintLayer let fadePresentation = kindFade.presentation() ?? kindFade let hintOffset = hintPresentation.transform.m41 let hintOpacity = hintPresentation.opacity let fadeOffset = fadePresentation.transform.m41 let fadeColors = fadePresentation.colors let targetOffset: CGFloat = visible ? 0 : Self.hintOffset let targetOpacity: Float = visible ? 1 : 0 let targetColors = visible ? Self.fadedMask : Self.opaqueMask CATransaction.begin() CATransaction.setDisableActions(true) hintLayer.removeAllAnimations() kindFade.removeAllAnimations() kindLabel.isHidden = false numberHint.isHidden = !(visible || animate) labelLayer.mask = visible || animate ? kindFade : nil hintLayer.transform = CATransform3DMakeTranslation(targetOffset, 0, 0) hintLayer.opacity = targetOpacity kindFade.transform = CATransform3DMakeTranslation(targetOffset, 0, 0) kindFade.colors = targetColors if animate { if !visible { CATransaction.setCompletionBlock { [weak self] in MainActor.assumeIsolated { guard let self, self.hintGeneration == generation, !self.numberHintVisible else { return } CATransaction.begin() CATransaction.setDisableActions(true) self.numberHint.isHidden = true self.kindLabel.layer?.mask = nil CATransaction.commit() } } } animateLayer(hintLayer, key: "transform.translation.x", from: hintOffset, to: targetOffset) animateLayer(hintLayer, key: "opacity", from: hintOpacity, to: targetOpacity) animateLayer(kindFade, key: "transform.translation.x", from: fadeOffset, to: targetOffset) animateLayer(kindFade, key: "colors", from: fadeColors, to: targetColors) } CATransaction.commit() } private func updateKindFadeGeometry() { let padding = Self.hintOffset let width = kindLabel.bounds.width + padding * 2 guard width > padding * 2 else { return } let badgeLeading = numberHint.frame.minX - kindLabel.frame.minX + padding CATransaction.begin() CATransaction.setDisableActions(true) // Extend the mask so its moving left edge never clips the start of the label. kindFade.bounds = CGRect(x: 0, y: 0, width: width, height: kindLabel.bounds.height) kindFade.position = CGPoint(x: kindLabel.bounds.midX, y: kindLabel.bounds.midY) kindFade.startPoint = CGPoint(x: max(0, min(1, (badgeLeading - 36) / width)), y: 0.5) kindFade.endPoint = CGPoint(x: max(0, min(1, (badgeLeading + 2) / width)), y: 0.5) CATransaction.commit() } private func animateLayer(_ layer: CALayer, key: String, from: Any?, to: Any) { let animation = CABasicAnimation(keyPath: key) animation.fromValue = from animation.toValue = to animation.duration = 0.16 animation.timingFunction = Self.timing layer.add(animation, forKey: key) } } @MainActor final class LauncherScrollView: NSScrollView { static let fadeHeight: CGFloat = 32 var edgeFadeHeight: CGFloat = LauncherScrollView.fadeHeight { didSet { needsLayout = true } } private let edgeFade = CAGradientLayer() override init(frame frameRect: NSRect) { super.init(frame: frameRect) wantsLayer = true edgeFade.colors = [NSColor.clear.cgColor, NSColor.black.cgColor, NSColor.black.cgColor, NSColor.clear.cgColor] edgeFade.startPoint = CGPoint(x: 0.5, y: 0) edgeFade.endPoint = CGPoint(x: 0.5, y: 1) layer?.mask = edgeFade } required init?(coder: NSCoder) { fatalError("Conjure creates its scroll view programmatically") } override func layout() { super.layout() let fade = min(0.5, edgeFadeHeight / max(bounds.height, 1)) CATransaction.begin() CATransaction.setDisableActions(true) edgeFade.frame = bounds edgeFade.locations = [0, NSNumber(value: Double(fade)), NSNumber(value: Double(1 - fade)), 1] CATransaction.commit() } } @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 final class LauncherWindow: NSWindow { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } var navigateSelection: ((Int) -> Void)? var dismiss: (() -> Void)? var escape: (() -> Void)? var dragEnded: (() -> Void)? var canUseNumberShortcuts: (() -> Bool)? var numberHintsChanged: (() -> Void)? var launchNumber: ((Int) -> Void)? var commandNavigation: ((String) -> Bool)? var commandShortcut: ((NSEvent) -> Bool)? var usesTextNavigation: (() -> Bool)? private var numberShortcuts = CommandNumberShortcuts() private var commandHoldTask: Task? var numberHintsVisible: Bool { numberShortcuts.isVisible } private func commandOnly(_ flags: NSEvent.ModifierFlags) -> Bool { flags.intersection([.command, .control, .option, .shift, .function]) == .command } private var numberShortcutsEligible: Bool { isKeyWindow && isVisible && attachedSheet == nil && canUseNumberShortcuts?() == true } func beginNumberHintSession() { resetNumberHints() numberShortcuts.reset() } func sampleNumberHints() { reevaluateNumberHints( modifierFlags: NSEvent.modifierFlags, eligible: numberShortcutsEligible, at: ProcessInfo.processInfo.systemUptime ) } /// The same sampling path accepts fixed flags, eligibility, and uptime for native smoke checks. func reevaluateNumberHints(modifierFlags: NSEvent.ModifierFlags, eligible: Bool, at time: TimeInterval) { let wasVisible = numberShortcuts.isVisible let began = numberShortcuts.update(commandOnly: commandOnly(modifierFlags), eligible: eligible, at: time) if !numberShortcuts.isHolding { commandHoldTask?.cancel() commandHoldTask = nil } else if began { commandHoldTask = Task { [weak self] in do { try await Task.sleep(for: .seconds(1)) } catch { return } self?.finishCommandHold(at: ProcessInfo.processInfo.systemUptime) } } if wasVisible != numberShortcuts.isVisible { numberHintsChanged?() } } func resetNumberHints(modifierFlags: NSEvent.ModifierFlags = NSEvent.modifierFlags) { commandHoldTask?.cancel() commandHoldTask = nil let wasVisible = numberShortcuts.isVisible if commandOnly(modifierFlags) { numberShortcuts.cancel() } else { numberShortcuts.reset() } if wasVisible { numberHintsChanged?() } } func finishCommandHold( at time: TimeInterval, modifierFlags: NSEvent.ModifierFlags = NSEvent.modifierFlags, eligible: Bool? = nil ) { commandHoldTask?.cancel() commandHoldTask = nil let wasVisible = numberShortcuts.isVisible _ = numberShortcuts.update( commandOnly: commandOnly(modifierFlags), eligible: eligible ?? numberShortcutsEligible, at: time ) _ = numberShortcuts.reveal(at: time) if wasVisible != numberShortcuts.isVisible { numberHintsChanged?() } } override func becomeKey() { super.becomeKey() sampleNumberHints() } override func beginSheet(_ sheetWindow: NSWindow, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) { resetNumberHints() super.beginSheet(sheetWindow, completionHandler: handler) } override func resignKey() { resetNumberHints() super.resignKey() } override func orderOut(_ sender: Any?) { resetNumberHints() super.orderOut(sender) } override func sendEvent(_ event: NSEvent) { if event.type == .keyDown, event.keyCode == 53, attachedSheet == nil, event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty { if let editor = firstResponder as? NotesTextView, editor.vimEnabled, editor.hasMarkedText() { super.sendEvent(event) return } cancelOperation(nil) return } let eligible = numberShortcutsEligible if event.type == .flagsChanged || (event.type == .keyUp && event.keyCode == 49) { reevaluateNumberHints(modifierFlags: event.modifierFlags, eligible: eligible, at: event.timestamp) } else if event.type == .keyDown { if eligible, commandOnly(event.modifierFlags), let index = numberShortcuts.index(for: event.charactersIgnoringModifiers ?? "") { resetNumberHints() if !event.isARepeat { launchNumber?(index) } return } resetNumberHints(modifierFlags: event.modifierFlags) } if event.type == .keyDown, attachedSheet == nil, event.modifierFlags.intersection([.control, .command, .option, .shift]) == .control { if commandNavigation?(event.charactersIgnoringModifiers?.lowercased() ?? "") == true { return } if usesTextNavigation?() != true { 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 performKeyEquivalent(with event: NSEvent) -> Bool { if attachedSheet == nil, commandShortcut?(event) == true { return true } return super.performKeyEquivalent(with: event) } override func cancelOperation(_ sender: Any?) { if let escape { escape() } else { dismiss?() } } override func performClose(_ sender: Any?) { dismiss?() } } @MainActor final class LauncherRowView: NSTableRowView { static let rowHeight: CGFloat = 38 static let textSize: CGFloat = 14 static let iconToTextSpacing: CGFloat = 12 var topSpacing: CGFloat = 0 { didSet { needsLayout = true; needsDisplay = true } } override func layout() { super.layout() for case let cell as ApplicationCell in subviews { cell.frame = NSRect(x: 0, y: topSpacing, width: bounds.width, height: bounds.height - topSpacing) } } override func drawSelection(in dirtyRect: NSRect) { var selectionBounds = bounds selectionBounds.origin.y += topSpacing selectionBounds.size.height -= topSpacing let selection = NSBezierPath(roundedRect: selectionBounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10) ThemePalette.selectionFill.setFill() selection.fill() let increasedContrast = NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast (increasedContrast ? ThemePalette.accent : ThemePalette.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 }