632 lines
30 KiB
Swift
632 lines
30 KiB
Swift
import AppKit
|
|
import ConjureKit
|
|
|
|
@MainActor
|
|
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
|
private var search: ActionsSearchField!
|
|
private var background: LauncherBackground!
|
|
private var entries: [ActionEntry] = []
|
|
private var visibleEntries: [Int] = []
|
|
private var availableEntries = Array(0..<6)
|
|
private var selectedEntry: Int? = 0
|
|
private var rowTopConstraints: [NSLayoutConstraint] = []
|
|
private var heightConstraint: NSLayoutConstraint?
|
|
private var empty: NSTextField!
|
|
private var applicationLabel: NSTextField!
|
|
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 selectLanguage: ((String) -> Void)?
|
|
var selectNote: ((UUID) -> Void)?
|
|
var browseNotes: (() -> Void)?
|
|
var newNote: (() -> Void)?
|
|
private enum ChoiceID: Equatable {
|
|
case language(String)
|
|
case note(UUID)
|
|
}
|
|
private struct SelectionChoice {
|
|
let id: ChoiceID
|
|
let title: String
|
|
let searchText: FuzzySearchText
|
|
}
|
|
private var choices: [SelectionChoice]?
|
|
private var matchingChoices: [SelectionChoice] = []
|
|
private var currentChoice: ChoiceID?
|
|
private var selectionTable: NSTableView?
|
|
private var selectionScroll: NSScrollView?
|
|
private var selectionViewportHeight: NSLayoutConstraint?
|
|
private var selectionAccessibilityLabel = ""
|
|
private var currentChoiceAccessibilityLabel = ""
|
|
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() { preparePresentation() }
|
|
|
|
private func preparePresentation() {
|
|
search = ActionsSearchField()
|
|
background = LauncherBackground(frame: .zero)
|
|
empty = NSTextField(labelWithString: "No matching actions")
|
|
applicationLabel = NSTextField(labelWithString: "")
|
|
rowTopConstraints = []
|
|
selectionTable = nil
|
|
selectionScroll = nil
|
|
selectionViewportHeight = nil
|
|
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),
|
|
ActionEntry(title: "Browse Notes", symbol: "list.bullet", color: SunsetPalette.text, commandKey: "P"),
|
|
ActionEntry(title: "New Note", symbol: "square.and.pencil", color: SunsetPalette.text, commandKey: "N")
|
|
]
|
|
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 = NSVisualEffectView()
|
|
content.material = .hudWindow
|
|
content.blendingMode = .behindWindow
|
|
content.state = .active
|
|
roundMaterialCorners(content, radius: 18)
|
|
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() }
|
|
search.commandKey = { [weak self] key in self?.handleCommandKey(key) ?? false }
|
|
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
|
|
if choices != nil {
|
|
height.isActive = false
|
|
let table = NSTableView()
|
|
table.style = .plain
|
|
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("Selection")))
|
|
table.headerView = nil
|
|
table.backgroundColor = .clear
|
|
table.selectionHighlightStyle = .none
|
|
table.rowHeight = LauncherRowView.rowHeight
|
|
table.intercellSpacing = .zero
|
|
table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle
|
|
table.autoresizingMask = [.width]
|
|
table.focusRingType = .none
|
|
table.dataSource = self
|
|
table.delegate = self
|
|
table.target = self
|
|
table.action = #selector(chooseSelectionRow)
|
|
table.setAccessibilityLabel(selectionAccessibilityLabel)
|
|
let scroll = NSScrollView()
|
|
scroll.documentView = table
|
|
scroll.hasVerticalScroller = true
|
|
scroll.autohidesScrollers = true
|
|
scroll.drawsBackground = false
|
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
content.addSubview(scroll)
|
|
let viewportHeight = scroll.heightAnchor.constraint(equalToConstant: LauncherRowView.rowHeight)
|
|
NSLayoutConstraint.activate([
|
|
scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
|
|
scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
|
|
scroll.topAnchor.constraint(equalTo: applicationLabel.bottomAnchor, constant: 10),
|
|
scroll.bottomAnchor.constraint(equalTo: search.topAnchor, constant: -8),
|
|
viewportHeight,
|
|
])
|
|
selectionTable = table
|
|
selectionScroll = scroll
|
|
selectionViewportHeight = viewportHeight
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
|
|
func showLanguages(_ choices: [(code: String, name: String)], selected: String,
|
|
source: Bool, relativeTo anchor: NSView) {
|
|
currentChoice = .language(selected)
|
|
selectionAccessibilityLabel = "Translation languages"
|
|
currentChoiceAccessibilityLabel = "Current language"
|
|
show(title: source ? "Source language" : "Target language", relativeTo: anchor, available: [],
|
|
searchAccessibilityLabel: source ? "Search source languages" : "Search target languages",
|
|
selectionChoices: choices.map {
|
|
SelectionChoice(id: .language($0.code), title: $0.name, searchText: FuzzySearchText($0.name))
|
|
}, selectionName: "languages")
|
|
}
|
|
|
|
func showNotes(_ notes: [(id: UUID, title: String)], selected: UUID?, relativeTo anchor: NSView) {
|
|
currentChoice = selected.map { .note($0) }
|
|
selectionAccessibilityLabel = "Notes"
|
|
currentChoiceAccessibilityLabel = "Current note"
|
|
show(title: "Browse Notes", relativeTo: anchor, available: [],
|
|
searchAccessibilityLabel: "Search notes", belowAnchor: true,
|
|
selectionChoices: notes.map {
|
|
SelectionChoice(id: .note($0.id), title: $0.title, searchText: FuzzySearchText($0.title))
|
|
}, selectionName: "notes")
|
|
}
|
|
|
|
func showNotesActions(relativeTo anchor: NSView) {
|
|
show(title: "Notes", relativeTo: anchor, available: [12, 13],
|
|
searchAccessibilityLabel: "Search notes actions", belowAnchor: true)
|
|
}
|
|
|
|
private func show(title: String, relativeTo anchor: NSView, available: [Int], emoji: Bool = false,
|
|
searchAccessibilityLabel: String = "Search application actions", belowAnchor: Bool = false,
|
|
selectionChoices: [SelectionChoice]? = nil, selectionName: String = "") {
|
|
if viewIfLoaded?.superview != nil {
|
|
if isShown, availableEntries == available, applicationLabel.stringValue == title { close(); return }
|
|
close(animated: false)
|
|
}
|
|
guard let window = anchor.window, let host = window.contentView else { return }
|
|
let pill = anchor.superview ?? anchor
|
|
choices = selectionChoices
|
|
// Reuse behavior, not native drawing state from a differently sized popup.
|
|
if isViewLoaded { preparePresentation() } else { _ = 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)
|
|
empty.stringValue = choices == nil ? "No matching actions" : "No matching \(selectionName)"
|
|
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: choices != nil ? "Search for \(selectionName)…" : 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)
|
|
host.layoutSubtreeIfNeeded()
|
|
background.needsDisplay = true
|
|
if let selectedEntry { selectionTable?.scrollRowToVisible(selectedEntry) }
|
|
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()
|
|
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.coveredPill = nil
|
|
self.isClosing = false
|
|
}
|
|
}
|
|
closed?()
|
|
}
|
|
|
|
func controlTextDidChange(_ notification: Notification) {
|
|
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let choices, let table = selectionTable {
|
|
matchingChoices = FuzzySearchText.results(for: query, in: choices, text: \.searchText) {
|
|
$0.title.localizedStandardCompare($1.title) == .orderedAscending
|
|
}
|
|
let selection = (query.isEmpty ? matchingChoices.firstIndex(where: { $0.id == currentChoice }) : nil)
|
|
?? (matchingChoices.isEmpty ? nil : 0)
|
|
for entry in entries { entry.view.isHidden = true }
|
|
selectionViewportHeight?.constant = CGFloat(max(min(matchingChoices.count, 6), 1)) * table.rowHeight
|
|
empty.isHidden = !matchingChoices.isEmpty
|
|
selectionScroll?.isHidden = matchingChoices.isEmpty
|
|
view.superview?.layoutSubtreeIfNeeded()
|
|
table.reloadData()
|
|
selectedEntry = selection
|
|
if let selectedEntry {
|
|
table.selectRowIndexes(IndexSet(integer: selectedEntry), byExtendingSelection: false)
|
|
table.scrollRowToVisible(selectedEntry)
|
|
}
|
|
updateSelection()
|
|
return
|
|
}
|
|
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) {
|
|
if let table = selectionTable {
|
|
guard !matchingChoices.isEmpty else { return }
|
|
let next = min(max((selectedEntry ?? 0) + offset, 0), matchingChoices.count - 1)
|
|
table.selectRowIndexes(IndexSet(integer: next), byExtendingSelection: false)
|
|
table.scrollRowToVisible(next)
|
|
return
|
|
}
|
|
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() {
|
|
if let table = selectionTable {
|
|
let visible = table.rows(in: table.visibleRect)
|
|
for row in visible.location..<NSMaxRange(visible) {
|
|
if let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) as? SelectionCell {
|
|
cell.entry.view.isSelected = selectedEntry == row
|
|
cell.entry.returnKey.isHidden = selectedEntry != row
|
|
}
|
|
}
|
|
return
|
|
}
|
|
for index in entries.indices {
|
|
let selected = selectedEntry == index
|
|
entries[index].view.isSelected = selected
|
|
entries[index].returnKey.isHidden = !selected && !entries[index].hasCommandKey
|
|
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
|
|
}
|
|
|
|
private func handleCommandKey(_ key: String) -> Bool {
|
|
guard isShown, availableEntries == [12, 13] else { return false }
|
|
switch key {
|
|
case "p": close(); browseNotes?()
|
|
case "n": close(); newNote?()
|
|
default: return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func numberOfRows(in tableView: NSTableView) -> Int { matchingChoices.count }
|
|
|
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
|
let identifier = NSUserInterfaceItemIdentifier("SelectionCell")
|
|
let cell = tableView.makeView(withIdentifier: identifier, owner: self) as? SelectionCell ?? SelectionCell()
|
|
cell.identifier = identifier
|
|
let choice = matchingChoices[row]
|
|
cell.entry.button.title = choice.title
|
|
cell.entry.button.setAccessibilityLabel(choice.title)
|
|
cell.entry.button.setAccessibilityValue(choice.id == currentChoice ? currentChoiceAccessibilityLabel : "")
|
|
cell.entry.button.target = self
|
|
cell.entry.button.action = #selector(chooseButton(_:))
|
|
cell.entry.button.tag = row
|
|
cell.entry.icon.isHidden = choice.id != currentChoice
|
|
cell.entry.view.isSelected = selectedEntry == row
|
|
cell.entry.returnKey.isHidden = selectedEntry != row
|
|
return cell
|
|
}
|
|
|
|
func tableViewSelectionDidChange(_ notification: Notification) {
|
|
guard let table = selectionTable else { return }
|
|
selectedEntry = table.selectedRow >= 0 ? table.selectedRow : nil
|
|
updateSelection()
|
|
}
|
|
|
|
@objc private func chooseSelectionRow() {
|
|
if let selectedEntry { chooseAction(at: selectedEntry) }
|
|
}
|
|
|
|
@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 else { return }
|
|
if choices != nil {
|
|
guard matchingChoices.indices.contains(index) else { return }
|
|
let id = matchingChoices[index].id
|
|
close()
|
|
switch id {
|
|
case .language(let code): selectLanguage?(code)
|
|
case .note(let id): selectNote?(id)
|
|
}
|
|
return
|
|
}
|
|
guard 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)
|
|
case 12: browseNotes?()
|
|
case 13: newNote?()
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct ActionEntry {
|
|
let view = LauncherRowView()
|
|
let button: NSButton
|
|
let icon: IconView
|
|
let returnKey: ShortcutIndicator
|
|
let hasCommandKey: Bool
|
|
|
|
init(title: String, symbol: String, color: NSColor, commandKey: String? = nil) {
|
|
hasCommandKey = commandKey != nil
|
|
returnKey = commandKey.map { ShortcutIndicator("⌘", $0) } ?? ShortcutIndicator("↵")
|
|
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 SelectionCell: NSView {
|
|
let entry = ActionEntry(title: "", symbol: "checkmark", color: SunsetPalette.text)
|
|
|
|
init() {
|
|
super.init(frame: .zero)
|
|
entry.view.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(entry.view)
|
|
NSLayoutConstraint.activate([
|
|
entry.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
entry.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
entry.view.topAnchor.constraint(equalTo: topAnchor),
|
|
entry.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
])
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("Conjure creates selection rows programmatically") }
|
|
}
|
|
|
|
@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)?
|
|
var commandKey: ((String) -> Bool)?
|
|
|
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command,
|
|
let key = event.charactersIgnoringModifiers?.lowercased() {
|
|
if key == "k" {
|
|
closeActions?()
|
|
return true
|
|
}
|
|
if commandKey?(key) == true { return true }
|
|
}
|
|
return super.performKeyEquivalent(with: event)
|
|
}
|
|
}
|