style: anchor launcher-styled actions above the footer
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
actions.closed = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.openButton.keyEquivalent = "\r"
|
||||
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
|
||||
else { self.dismiss(restoringFocus: false) }
|
||||
}
|
||||
@@ -143,7 +144,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
guard !isLaunching, !actions.isShown, window?.attachedSheet == nil else { return }
|
||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||
dismiss(restoringFocus: false)
|
||||
}
|
||||
|
||||
@@ -495,6 +496,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
let application = results[tableView.selectedRow]
|
||||
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
|
||||
actions.show(for: application, relativeTo: actionsButton)
|
||||
openButton.keyEquivalent = actions.isShown ? "" : "\r"
|
||||
}
|
||||
|
||||
private func reviewUninstall(_ application: Application) {
|
||||
@@ -613,7 +615,7 @@ final class LauncherRowView: NSTableRowView {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func roundMaterialCorners(_ view: NSVisualEffectView, radius: CGFloat) {
|
||||
func roundMaterialCorners(_ view: NSVisualEffectView, radius: CGFloat) {
|
||||
let size = NSSize(width: radius * 2 + 1, height: radius * 2 + 1)
|
||||
let mask = NSImage(size: size, flipped: false) { rect in
|
||||
NSColor.black.setFill()
|
||||
|
||||
Reference in New Issue
Block a user