202 lines
9.9 KiB
Swift
202 lines
9.9 KiB
Swift
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)
|
|
}
|
|
}
|