style: anchor launcher-styled actions above the footer

This commit is contained in:
2026-10-12 15:48:13 +02:00
parent 2598f6f1dd
commit eb99269c68
3 changed files with 125 additions and 41 deletions
@@ -2,35 +2,40 @@ import AppKit
import ConjureKit
@MainActor
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSPopoverDelegate {
private let popover = NSPopover()
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 { popover.isShown }
init() {
super.init(nibName: nil, bundle: nil)
popover.contentViewController = self
popover.behavior = .semitransient
popover.delegate = self
}
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 = .popover
content.material = .hudWindow
content.blendingMode = .withinWindow
content.state = .active
content.appearance = NSAppearance(named: .darkAqua)
view = content
preferredContentSize = NSSize(width: 340, height: 150)
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)
@@ -43,58 +48,133 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
action.target = self
action.action = #selector(chooseAction)
action.setAccessibilityLabel("Uninstall application")
search.placeholderString = "Search actions…"
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 stack = NSStackView(views: [applicationLabel, action, empty, search])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
content.addSubview(stack)
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([
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)
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 }
_ = view
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))
popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
view.window?.makeFirstResponder(search)
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() { popover.performClose(nil) }
func popoverDidClose(_ notification: Notification) { closed?() }
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
action.isHidden = !matches
row.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.
if !row.isHidden { chooseAction() }
case #selector(NSResponder.cancelOperation(_:)): close()
case #selector(NSResponder.moveUp(_:)), #selector(NSResponder.moveDown(_:)): return true
default: return false
}
return true