Files
conjure/Sources/Conjure/ApplicationActionsController.swift
T

122 lines
5.0 KiB
Swift

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)
}
}