import AppKit import ConjureKit @MainActor final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate { private let search = ActionsSearchField() private let action = NSButton(title: "Uninstall application", target: nil, action: nil) private let row = LauncherRowView() private let empty = NSTextField(labelWithString: "No matching actions") private let applicationLabel = NSTextField(labelWithString: "") private var mouseMonitor: Any? var uninstall: (() -> Void)? var closed: (() -> Void)? var isShown: Bool { viewIfLoaded?.superview != nil } init() { super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("Conjure creates actions programmatically") } override func loadView() { view = NSView() view.wantsLayer = true view.layer?.shadowColor = NSColor.black.cgColor view.layer?.shadowOpacity = 0.35 view.layer?.shadowRadius = 14 view.layer?.shadowOffset = NSSize(width: 0, height: -4) let content = NSVisualEffectView() content.material = .hudWindow content.blendingMode = .withinWindow content.state = .active roundMaterialCorners(content, radius: 18) content.layer?.borderWidth = 0.5 let background = LauncherBackground(frame: .zero) background.setAccessibilityElement(false) content.addSubview(background) applicationLabel.textColor = SunsetPalette.secondaryText applicationLabel.lineBreakMode = .byTruncatingTail applicationLabel.font = .systemFont(ofSize: 12, weight: .medium) row.isSelected = true action.isBordered = false action.alignment = .left action.font = .systemFont(ofSize: 16, weight: .medium) action.contentTintColor = .systemRed action.attributedTitle = NSAttributedString(string: action.title, attributes: [ .foregroundColor: NSColor.systemRed, .font: NSFont.systemFont(ofSize: 16, weight: .medium) ]) action.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil) action.imagePosition = .imageLeading action.target = self action.action = #selector(chooseAction) action.setAccessibilityLabel("Uninstall application") 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) search.font = .systemFont(ofSize: 16) search.textColor = SunsetPalette.text search.placeholderAttributedString = NSAttributedString(string: "Search for actions…", attributes: [ .foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 16) ]) search.isBordered = false search.drawsBackground = false search.focusRingType = .none if let cell = search.cell as? NSSearchFieldCell { cell.searchButtonCell = nil cell.cancelButtonCell = nil } search.delegate = self search.sendsSearchStringImmediately = true search.setAccessibilityLabel("Search application actions") search.closeActions = { [weak self] in self?.close() } empty.textColor = SunsetPalette.secondaryText empty.font = .systemFont(ofSize: 14) empty.isHidden = true let divider = NSBox() divider.boxType = .separator content.translatesAutoresizingMaskIntoConstraints = false view.addSubview(content) for child in [background, applicationLabel, row, empty, divider, search] { child.translatesAutoresizingMaskIntoConstraints = false if child !== background { content.addSubview(child) } } for child in [action, returnKey] { child.translatesAutoresizingMaskIntoConstraints = false row.addSubview(child) } NSLayoutConstraint.activate([ view.widthAnchor.constraint(equalToConstant: 360), view.heightAnchor.constraint(equalToConstant: 148), content.leadingAnchor.constraint(equalTo: view.leadingAnchor), content.trailingAnchor.constraint(equalTo: view.trailingAnchor), content.topAnchor.constraint(equalTo: view.topAnchor), content.bottomAnchor.constraint(equalTo: view.bottomAnchor), background.leadingAnchor.constraint(equalTo: content.leadingAnchor), background.trailingAnchor.constraint(equalTo: content.trailingAnchor), background.topAnchor.constraint(equalTo: content.topAnchor), background.bottomAnchor.constraint(equalTo: content.bottomAnchor), applicationLabel.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), applicationLabel.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), applicationLabel.topAnchor.constraint(equalTo: content.topAnchor, constant: 14), row.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8), row.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8), row.topAnchor.constraint(equalTo: applicationLabel.bottomAnchor, constant: 10), row.heightAnchor.constraint(equalToConstant: 44), action.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 10), action.centerYAnchor.constraint(equalTo: row.centerYAnchor), action.trailingAnchor.constraint(equalTo: returnKey.leadingAnchor, constant: -12), returnKey.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12), returnKey.centerYAnchor.constraint(equalTo: row.centerYAnchor), returnKey.widthAnchor.constraint(equalToConstant: 20), returnKey.heightAnchor.constraint(equalToConstant: 20), empty.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), empty.centerYAnchor.constraint(equalTo: row.centerYAnchor), divider.leadingAnchor.constraint(equalTo: content.leadingAnchor), divider.trailingAnchor.constraint(equalTo: content.trailingAnchor), divider.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -10), search.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), search.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), search.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12), search.heightAnchor.constraint(equalToConstant: 28) ]) content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor returnKey.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.35).cgColor } func show(for application: Application, relativeTo anchor: NSView) { if isShown { close(); return } guard let window = anchor.window, let host = window.contentView else { return } let pill = anchor.superview ?? anchor applicationLabel.stringValue = application.name search.stringValue = "" controlTextDidChange(Notification(name: NSControl.textDidChangeNotification)) view.translatesAutoresizingMaskIntoConstraints = false host.addSubview(view) NSLayoutConstraint.activate([ view.trailingAnchor.constraint(equalTo: pill.trailingAnchor), view.bottomAnchor.constraint(equalTo: pill.topAnchor, constant: -8) ]) window.makeFirstResponder(search) (search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self, weak pill] event in MainActor.assumeIsolated { guard let self, self.isShown, event.window === self.view.window else { return } let point = self.view.convert(event.locationInWindow, from: nil) let pillPoint = pill?.convert(event.locationInWindow, from: nil) if !self.view.bounds.contains(point), !(pillPoint.map { pill?.bounds.contains($0) == true } ?? false) { self.close() } } return event } } func close() { guard isShown else { return } if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) } mouseMonitor = nil view.removeFromSuperview() closed?() } func controlTextDidChange(_ notification: Notification) { let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let matches = query.isEmpty || action.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil row.isHidden = !matches empty.isHidden = matches } func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { switch selector { case #selector(NSResponder.insertNewline(_:)): if !row.isHidden { chooseAction() } case #selector(NSResponder.cancelOperation(_:)): close() case #selector(NSResponder.moveUp(_:)), #selector(NSResponder.moveDown(_:)): return true default: return false } return true } @objc private func chooseAction() { close() uninstall?() } } @MainActor private final class ActionsSearchField: NSSearchField { var closeActions: (() -> Void)? override func performKeyEquivalent(with event: NSEvent) -> Bool { if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command, event.charactersIgnoringModifiers?.lowercased() == "k" { closeActions?() return true } return super.performKeyEquivalent(with: event) } }