feat: add contextual actions and recoverable application uninstall
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user