import AppKit import ConjureKit @MainActor final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSPopoverDelegate { private let popover = NSPopover() private let search = ActionsSearchField() private let action = NSButton(title: "Uninstall application", target: nil, action: nil) private let empty = NSTextField(labelWithString: "No matching actions") private let applicationLabel = NSTextField(labelWithString: "") var uninstall: (() -> Void)? var closed: (() -> Void)? var isShown: Bool { popover.isShown } init() { super.init(nibName: nil, bundle: nil) popover.contentViewController = self popover.behavior = .semitransient popover.delegate = self } required init?(coder: NSCoder) { fatalError("Conjure creates actions programmatically") } override func loadView() { let content = NSVisualEffectView() content.material = .popover content.state = .active content.appearance = NSAppearance(named: .darkAqua) view = content preferredContentSize = NSSize(width: 340, height: 150) applicationLabel.textColor = SunsetPalette.secondaryText applicationLabel.lineBreakMode = .byTruncatingTail applicationLabel.font = .systemFont(ofSize: 12, weight: .medium) 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") search.placeholderString = "Search actions…" search.delegate = self search.sendsSearchStringImmediately = true search.setAccessibilityLabel("Search application actions") search.closeActions = { [weak self] in self?.close() } empty.textColor = SunsetPalette.secondaryText empty.isHidden = true let stack = NSStackView(views: [applicationLabel, action, empty, search]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false content.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 14), search.widthAnchor.constraint(equalTo: stack.widthAnchor), action.widthAnchor.constraint(equalTo: stack.widthAnchor), applicationLabel.widthAnchor.constraint(equalTo: stack.widthAnchor) ]) } func show(for application: Application, relativeTo anchor: NSView) { if isShown { close(); return } _ = view applicationLabel.stringValue = application.name search.stringValue = "" controlTextDidChange(Notification(name: NSControl.textDidChangeNotification)) popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY) view.window?.makeFirstResponder(search) } func close() { popover.performClose(nil) } func popoverDidClose(_ notification: Notification) { 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 action.isHidden = !matches empty.isHidden = matches } func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { switch selector { case #selector(NSResponder.insertNewline(_:)): if !action.isHidden { chooseAction() } case #selector(NSResponder.cancelOperation(_:)): close() case #selector(NSResponder.moveUp(_:)), #selector(NSResponder.moveDown(_:)): return true // There is currently one contextual action. 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) } }