Files
conjure/Sources/Conjure/ApplicationActionsController.swift
T

434 lines
21 KiB
Swift

import AppKit
import ConjureKit
@MainActor
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate {
private let search = ActionsSearchField()
private let background = LauncherBackground(frame: .zero)
private var material: NSVisualEffectView?
private let entries = [
ActionEntry(title: "Open", symbol: "arrow.up.forward.app", color: SunsetPalette.text),
ActionEntry(title: "Favorite", symbol: "star", color: SunsetPalette.text),
ActionEntry(title: "Show in Finder", symbol: "finder", color: SunsetPalette.text),
ActionEntry(title: "Reset ranking", symbol: "arrow.counterclockwise", color: SunsetPalette.text),
ActionEntry(title: "Quit application", symbol: "stop.circle", color: SunsetPalette.text),
ActionEntry(title: "Uninstall application", symbol: "trash", color: .systemRed),
ActionEntry(title: "Copy Emoji", symbol: "doc.on.doc", color: SunsetPalette.text),
ActionEntry(title: "Change source language…", symbol: "character.bubble", color: SunsetPalette.text),
ActionEntry(title: "Change target language…", symbol: "character.bubble", color: SunsetPalette.text),
ActionEntry(title: "Swap languages", symbol: "arrow.left.arrow.right", color: SunsetPalette.text),
ActionEntry(title: "MyMemory (no key)", symbol: "checkmark", color: SunsetPalette.text),
ActionEntry(title: "OpenAI · gpt-6-luna", symbol: "checkmark", color: SunsetPalette.text)
]
private var visibleEntries: [Int] = []
private var availableEntries = Array(0..<6)
private var selectedEntry: Int? = 0
private var rowTopConstraints: [NSLayoutConstraint] = []
private var heightConstraint: NSLayoutConstraint?
private let empty = NSTextField(labelWithString: "No matching actions")
private let applicationLabel = NSTextField(labelWithString: "")
private var mouseMonitor: Any?
private weak var coveredPill: NSView?
private var coveredPillAlpha: CGFloat = 1
private var isClosing = false
private var transitionGeneration = 0
private var panel: ActionsPanelView { view as! ActionsPanelView }
var open: (() -> Void)?
var copyEmoji: (() -> Void)?
var toggleFavorite: (() -> Void)?
var isFavorite = false
var showInFinder: (() -> Void)?
var resetRanking: (() -> Void)?
var quit: (() -> Void)?
var uninstall: (() -> Void)?
var changeSourceLanguage: (() -> Void)?
var changeTargetLanguage: (() -> Void)?
var swapLanguages: (() -> Void)?
var selectProvider: ((Bool) -> Void)?
private var currentProviderEntry: Int?
var closed: (() -> Void)?
var isShown: Bool { viewIfLoaded?.superview != nil && !isClosing }
init() { super.init(nibName: nil, bundle: nil) }
required init?(coder: NSCoder) { fatalError("Conjure creates actions programmatically") }
override func loadView() {
view = ActionsPanelView()
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 = NSView()
content.wantsLayer = true
content.layer?.cornerRadius = 18
content.layer?.masksToBounds = true
content.layer?.borderWidth = 0.5
background.setAccessibilityElement(false)
content.addSubview(background)
applicationLabel.textColor = SunsetPalette.secondaryText
applicationLabel.lineBreakMode = .byTruncatingTail
applicationLabel.font = .systemFont(ofSize: 12, weight: .medium)
search.font = .systemFont(ofSize: LauncherRowView.textSize)
search.textColor = SunsetPalette.text
search.placeholderAttributedString = NSAttributedString(string: "Search for actions…", attributes: [
.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize)
])
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
content.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(content)
for child in [background, applicationLabel, empty, search] {
child.translatesAutoresizingMaskIntoConstraints = false
if child !== background { content.addSubview(child) }
}
for (index, entry) in entries.enumerated() {
entry.view.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(entry.view)
entry.button.tag = index
entry.button.target = self
entry.button.action = #selector(chooseButton(_:))
entry.view.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(chooseRow(_:))))
let top = entry.view.topAnchor.constraint(equalTo: applicationLabel.bottomAnchor,
constant: 10 + CGFloat(index) * LauncherRowView.rowHeight)
rowTopConstraints.append(top)
NSLayoutConstraint.activate([
entry.view.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
entry.view.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
top
])
}
let height = view.heightAnchor.constraint(equalToConstant: 116 + CGFloat(entries.count - 1) * LauncherRowView.rowHeight)
heightConstraint = height
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 360), height,
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),
empty.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
empty.centerYAnchor.constraint(equalTo: entries[0].view.centerYAnchor),
search.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
search.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
search.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -8),
search.heightAnchor.constraint(equalToConstant: 24)
])
content.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
updateSelection()
}
func show(for application: Application, relativeTo anchor: NSView) {
show(title: application.name, relativeTo: anchor,
available: application.command == nil ? Array(0..<6) : [0, 1, 3])
}
func showEmoji(named name: String, relativeTo anchor: NSView) {
show(title: name, relativeTo: anchor, available: [0, 6, 1], emoji: true,
searchAccessibilityLabel: "Search emoji actions")
}
func showTranslation(relativeTo anchor: NSView) {
show(title: "Translation", relativeTo: anchor, available: [7, 8, 9],
searchAccessibilityLabel: "Search translation actions")
}
func showProviders(selectedOpenAI: Bool, relativeTo anchor: NSView) {
currentProviderEntry = selectedOpenAI ? 11 : 10
show(title: "Translation provider", relativeTo: anchor, available: [10, 11],
searchAccessibilityLabel: "Search translation providers", belowAnchor: true)
if isShown {
selectedEntry = currentProviderEntry
updateSelection()
}
}
private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false) {
if viewIfLoaded?.superview != nil {
if isShown, availableEntries == available { close(); return }
close(animated: false)
}
guard let window = anchor.window, let host = window.contentView else { return }
let pill = anchor.superview ?? anchor
_ = view
applicationLabel.stringValue = title
availableEntries = available
let openTitle = emoji ? "Copy and Paste" : "Open"
entries[0].button.title = openTitle
entries[0].button.setAccessibilityLabel(openTitle)
entries[0].icon.image = NSImage(systemSymbolName: emoji ? "doc.on.clipboard" : "arrow.up.forward.app", accessibilityDescription: nil)
search.setAccessibilityLabel(searchAccessibilityLabel)
let favorite = entries[1]
let favoriteTitle = isFavorite ? "Remove Favorite" : "Favorite"
favorite.button.attributedTitle = NSAttributedString(string: favoriteTitle, attributes: [
.foregroundColor: SunsetPalette.text, .font: NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
])
favorite.button.setAccessibilityLabel(favoriteTitle)
favorite.icon.image = NSImage(systemSymbolName: isFavorite ? "star.fill" : "star", accessibilityDescription: nil)
for index in [10, 11] {
entries[index].icon.isHidden = currentProviderEntry != index
}
search.placeholderAttributedString = NSAttributedString(
string: belowAnchor ? "Search for providers…" : "Search for actions…",
attributes: [.foregroundColor: SunsetPalette.secondaryText,
.font: NSFont.systemFont(ofSize: LauncherRowView.textSize)])
search.stringValue = ""
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
transitionGeneration += 1
isClosing = false
panel.acceptsInteraction = true
panel.setAccessibilityHidden(false)
if view.superview == nil {
view.alphaValue = 0
view.translatesAutoresizingMaskIntoConstraints = false
host.addSubview(view)
// Paint only the popup's bounds, using the window's gradient coordinates.
background.canvas = host
NSLayoutConstraint.activate([
view.trailingAnchor.constraint(equalTo: pill.trailingAnchor),
belowAnchor
? view.topAnchor.constraint(equalTo: pill.topAnchor)
: view.bottomAnchor.constraint(equalTo: pill.bottomAnchor)
])
coveredPill = pill
coveredPillAlpha = pill.alphaValue
}
coveredPill?.setAccessibilityHidden(true)
// A backdrop belongs to one presentation; do not move a cached native material
// between the footer Actions popup and the header provider popup.
material?.removeFromSuperview()
let backdrop = NSVisualEffectView()
backdrop.material = .hudWindow
backdrop.blendingMode = .behindWindow
backdrop.state = .active
roundMaterialCorners(backdrop, radius: 18)
backdrop.translatesAutoresizingMaskIntoConstraints = false
let content = background.superview!
content.addSubview(backdrop, positioned: .below, relativeTo: background)
NSLayoutConstraint.activate([
backdrop.leadingAnchor.constraint(equalTo: content.leadingAnchor),
backdrop.trailingAnchor.constraint(equalTo: content.trailingAnchor),
backdrop.topAnchor.constraint(equalTo: content.topAnchor),
backdrop.bottomAnchor.constraint(equalTo: content.bottomAnchor),
])
material = backdrop
host.layoutSubtreeIfNeeded()
background.needsDisplay = true
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.16
view.animator().alphaValue = 1
coveredPill?.animator().alphaValue = 0
}
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(animated: Bool = true) {
guard viewIfLoaded?.superview != nil, !isClosing || !animated else { return }
transitionGeneration += 1
let generation = transitionGeneration
isClosing = true
panel.acceptsInteraction = false
panel.setAccessibilityHidden(true)
if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) }
mouseMonitor = nil
coveredPill?.setAccessibilityHidden(false)
if !animated {
view.removeFromSuperview()
material?.removeFromSuperview()
material = nil
coveredPill?.layer?.removeAllAnimations()
coveredPill?.alphaValue = coveredPillAlpha
coveredPill = nil
isClosing = false
closed?()
return
}
// Finish teardown even if the presenting Command releases this controller.
NSAnimationContext.runAnimationGroup { context in
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.12
view.animator().alphaValue = 0
coveredPill?.animator().alphaValue = coveredPillAlpha
} completionHandler: { [self] in
MainActor.assumeIsolated {
guard self.transitionGeneration == generation, self.isClosing else { return }
self.view.removeFromSuperview()
self.material?.removeFromSuperview()
self.material = nil
self.coveredPill = nil
self.isClosing = false
}
}
closed?()
}
func controlTextDidChange(_ notification: Notification) {
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
visibleEntries = availableEntries.filter {
query.isEmpty || entries[$0].button.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
}
selectedEntry = visibleEntries.first
for index in entries.indices {
let position = visibleEntries.firstIndex(of: index)
entries[index].view.isHidden = position == nil
rowTopConstraints[index].constant = 10 + CGFloat(position ?? 0) * LauncherRowView.rowHeight
}
heightConstraint?.constant = 116 + CGFloat(max(visibleEntries.count, 1) - 1) * LauncherRowView.rowHeight
empty.isHidden = !visibleEntries.isEmpty
updateSelection()
}
func moveSelection(by offset: Int) {
guard !visibleEntries.isEmpty else { return }
let current = selectedEntry.flatMap { visibleEntries.firstIndex(of: $0) } ?? 0
selectedEntry = visibleEntries[min(max(current + offset, 0), visibleEntries.count - 1)]
updateSelection()
}
private func updateSelection() {
for index in entries.indices {
let selected = selectedEntry == index
entries[index].view.isSelected = selected
entries[index].returnKey.isHidden = !selected
let current = currentProviderEntry == index ? ", Current provider" : ""
entries[index].button.setAccessibilityValue((selected ? "Selected" : "Not selected") + current)
}
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
handle(selector)
}
func handle(_ selector: Selector) -> Bool {
switch selector {
case #selector(NSResponder.cancelOperation(_:)):
guard isShown else { return false }
close()
case #selector(NSResponder.insertNewline(_:)):
if let selectedEntry { chooseAction(at: selectedEntry) }
case #selector(NSResponder.moveUp(_:)): moveSelection(by: -1)
case #selector(NSResponder.moveDown(_:)): moveSelection(by: 1)
default: return false
}
return true
}
@objc private func chooseButton(_ sender: NSButton) { chooseAction(at: sender.tag) }
@objc private func chooseRow(_ sender: NSGestureRecognizer) {
if let index = entries.firstIndex(where: { $0.view === sender.view }) { chooseAction(at: index) }
}
private func chooseAction(at index: Int) {
guard isShown, visibleEntries.contains(index) else { return }
close()
switch index {
case 0: open?()
case 1: toggleFavorite?()
case 2: showInFinder?()
case 3: resetRanking?()
case 4: quit?()
case 5: uninstall?()
case 6: copyEmoji?()
case 7: changeSourceLanguage?()
case 8: changeTargetLanguage?()
case 9: swapLanguages?()
case 10: selectProvider?(false)
case 11: selectProvider?(true)
default: break
}
}
}
@MainActor
private struct ActionEntry {
let view = LauncherRowView()
let button: NSButton
let icon: IconView
let returnKey = ShortcutIndicator("↵")
init(title: String, symbol: String, color: NSColor) {
let font = NSFont.systemFont(ofSize: LauncherRowView.textSize, weight: .medium)
icon = IconView(font: font)
button = NSButton(title: title, target: nil, action: nil)
button.isBordered = false
button.alignment = .left
button.font = font
button.contentTintColor = color
button.attributedTitle = NSAttributedString(string: title, attributes: [
.foregroundColor: color, .font: font
])
button.setAccessibilityLabel(title)
icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)
?? (symbol == "finder" ? NSWorkspace.shared.icon(forFile: "/System/Library/CoreServices/Finder.app") : nil)
icon.contentTintColor = color
for child in [icon, button, returnKey] {
child.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(child)
}
NSLayoutConstraint.activate([
view.heightAnchor.constraint(equalToConstant: LauncherRowView.rowHeight),
icon.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
icon.centerYAnchor.constraint(equalTo: view.centerYAnchor),
button.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: LauncherRowView.iconToTextSpacing),
button.centerYAnchor.constraint(equalTo: view.centerYAnchor),
button.trailingAnchor.constraint(equalTo: returnKey.leadingAnchor, constant: -12),
returnKey.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
returnKey.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
}
@MainActor
private final class ActionsPanelView: NSView {
var acceptsInteraction = true
override func hitTest(_ point: NSPoint) -> NSView? {
acceptsInteraction ? super.hitTest(point) : nil
}
}
@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)
}
}