style: anchor launcher-styled actions above the footer
This commit is contained in:
@@ -79,6 +79,8 @@ Applications with identical display names show a muted containing-folder hint be
|
|||||||
|
|
||||||
Select an application and open **Actions ⌘K**. The red **Uninstall application** action opens a review sheet with file checkboxes, paths, sizes, and a selected-item count. Filtering by name or path does not change selections. Up/Down highlights rows; Space toggles the highlighted eligible item when the file list has focus. Back or Escape returns to the launcher. Return or the uninstall button opens a final confirmation; nothing is moved merely by choosing the action.
|
Select an application and open **Actions ⌘K**. The red **Uninstall application** action opens a review sheet with file checkboxes, paths, sizes, and a selected-item count. Filtering by name or path does not change selections. Up/Down highlights rows; Space toggles the highlighted eligible item when the file list has focus. Back or Escape returns to the launcher. Return or the uninstall button opens a final confirmation; nothing is moved merely by choosing the action.
|
||||||
|
|
||||||
|
The actions selector is an arrowless panel inside the launcher, aligned to the right edge of the footer action pill and opening directly above it. It shares the launcher's frosted Sunset background, rounded selection styling, and Return key badge. The application name appears at the top and borderless action search stays at the bottom below a divider. Escape, Command-K, an outside click, or switching away closes it. Closing while Conjure remains active restores launcher search focus; switching away does not steal focus back. Return in action search chooses the visible action rather than launching the underlying application.
|
||||||
|
|
||||||
Discovery includes the application bundle and exact bundle-ID paths in your Library: Preferences, Caches, Application Support, Logs, Saved Application State, Application Scripts, and Containers. Embedded extension identifiers are included. App-group entitlements are read from validated native code signatures; related shared Group Containers and Application Scripts remain unchecked because other applications may use them. Conjure does not guess name-based leftovers or include package receipts. This is conservative discovery, not a guarantee that every associated file is found.
|
Discovery includes the application bundle and exact bundle-ID paths in your Library: Preferences, Caches, Application Support, Logs, Saved Application State, Application Scripts, and Containers. Embedded extension identifiers are included. App-group entitlements are read from validated native code signatures; related shared Group Containers and Application Scripts remain unchecked because other applications may use them. Conjure does not guess name-based leftovers or include package receipts. This is conservative discovery, not a guarantee that every associated file is found.
|
||||||
|
|
||||||
System-managed applications, Conjure itself, invalid bundles, symbolic-link paths, locked items, and files owned by another user are not removable. Locked candidates show a reason. A locked application bundle can still have eligible user-owned associated data; removing that data does not remove the locked application itself. No administrator authorization is requested. Running instances of the selected application and its embedded applications are asked to quit gracefully; refusal or timeout stops the operation before moving files.
|
System-managed applications, Conjure itself, invalid bundles, symbolic-link paths, locked items, and files owned by another user are not removable. Locked candidates show a reason. A locked application bundle can still have eligible user-owned associated data; removing that data does not remove the locked application itself. No administrator authorization is requested. Running instances of the selected application and its embedded applications are asked to quit gracefully; refusal or timeout stops the operation before moving files.
|
||||||
|
|||||||
@@ -2,35 +2,40 @@ import AppKit
|
|||||||
import ConjureKit
|
import ConjureKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSPopoverDelegate {
|
final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate {
|
||||||
private let popover = NSPopover()
|
|
||||||
private let search = ActionsSearchField()
|
private let search = ActionsSearchField()
|
||||||
private let action = NSButton(title: "Uninstall application", target: nil, action: nil)
|
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 empty = NSTextField(labelWithString: "No matching actions")
|
||||||
private let applicationLabel = NSTextField(labelWithString: "")
|
private let applicationLabel = NSTextField(labelWithString: "")
|
||||||
|
private var mouseMonitor: Any?
|
||||||
var uninstall: (() -> Void)?
|
var uninstall: (() -> Void)?
|
||||||
var closed: (() -> Void)?
|
var closed: (() -> Void)?
|
||||||
var isShown: Bool { popover.isShown }
|
var isShown: Bool { viewIfLoaded?.superview != nil }
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
popover.contentViewController = self
|
|
||||||
popover.behavior = .semitransient
|
|
||||||
popover.delegate = self
|
|
||||||
}
|
|
||||||
|
|
||||||
|
init() { super.init(nibName: nil, bundle: nil) }
|
||||||
required init?(coder: NSCoder) { fatalError("Conjure creates actions programmatically") }
|
required init?(coder: NSCoder) { fatalError("Conjure creates actions programmatically") }
|
||||||
|
|
||||||
override func loadView() {
|
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()
|
let content = NSVisualEffectView()
|
||||||
content.material = .popover
|
content.material = .hudWindow
|
||||||
|
content.blendingMode = .withinWindow
|
||||||
content.state = .active
|
content.state = .active
|
||||||
content.appearance = NSAppearance(named: .darkAqua)
|
roundMaterialCorners(content, radius: 18)
|
||||||
view = content
|
content.layer?.borderWidth = 0.5
|
||||||
preferredContentSize = NSSize(width: 340, height: 150)
|
let background = LauncherBackground(frame: .zero)
|
||||||
|
background.setAccessibilityElement(false)
|
||||||
|
content.addSubview(background)
|
||||||
applicationLabel.textColor = SunsetPalette.secondaryText
|
applicationLabel.textColor = SunsetPalette.secondaryText
|
||||||
applicationLabel.lineBreakMode = .byTruncatingTail
|
applicationLabel.lineBreakMode = .byTruncatingTail
|
||||||
applicationLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
applicationLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
row.isSelected = true
|
||||||
action.isBordered = false
|
action.isBordered = false
|
||||||
action.alignment = .left
|
action.alignment = .left
|
||||||
action.font = .systemFont(ofSize: 16, weight: .medium)
|
action.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
@@ -43,58 +48,133 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
|
|||||||
action.target = self
|
action.target = self
|
||||||
action.action = #selector(chooseAction)
|
action.action = #selector(chooseAction)
|
||||||
action.setAccessibilityLabel("Uninstall application")
|
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.delegate = self
|
||||||
search.sendsSearchStringImmediately = true
|
search.sendsSearchStringImmediately = true
|
||||||
search.setAccessibilityLabel("Search application actions")
|
search.setAccessibilityLabel("Search application actions")
|
||||||
search.closeActions = { [weak self] in self?.close() }
|
search.closeActions = { [weak self] in self?.close() }
|
||||||
empty.textColor = SunsetPalette.secondaryText
|
empty.textColor = SunsetPalette.secondaryText
|
||||||
|
empty.font = .systemFont(ofSize: 14)
|
||||||
empty.isHidden = true
|
empty.isHidden = true
|
||||||
let stack = NSStackView(views: [applicationLabel, action, empty, search])
|
let divider = NSBox()
|
||||||
stack.orientation = .vertical
|
divider.boxType = .separator
|
||||||
stack.alignment = .leading
|
content.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stack.spacing = 12
|
view.addSubview(content)
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
for child in [background, applicationLabel, row, empty, divider, search] {
|
||||||
content.addSubview(stack)
|
child.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
if child !== background { content.addSubview(child) }
|
||||||
|
}
|
||||||
|
for child in [action, returnKey] {
|
||||||
|
child.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
row.addSubview(child)
|
||||||
|
}
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
view.widthAnchor.constraint(equalToConstant: 360),
|
||||||
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16),
|
view.heightAnchor.constraint(equalToConstant: 148),
|
||||||
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 14),
|
content.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
search.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
content.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
action.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
content.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
applicationLabel.widthAnchor.constraint(equalTo: stack.widthAnchor)
|
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) {
|
func show(for application: Application, relativeTo anchor: NSView) {
|
||||||
if isShown { close(); return }
|
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
|
applicationLabel.stringValue = application.name
|
||||||
search.stringValue = ""
|
search.stringValue = ""
|
||||||
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
|
controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
|
||||||
popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY)
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.window?.makeFirstResponder(search)
|
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 close() {
|
||||||
|
guard isShown else { return }
|
||||||
func popoverDidClose(_ notification: Notification) { closed?() }
|
if let mouseMonitor { NSEvent.removeMonitor(mouseMonitor) }
|
||||||
|
mouseMonitor = nil
|
||||||
|
view.removeFromSuperview()
|
||||||
|
closed?()
|
||||||
|
}
|
||||||
|
|
||||||
func controlTextDidChange(_ notification: Notification) {
|
func controlTextDidChange(_ notification: Notification) {
|
||||||
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let matches = query.isEmpty || action.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
|
let matches = query.isEmpty || action.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
|
||||||
action.isHidden = !matches
|
row.isHidden = !matches
|
||||||
empty.isHidden = matches
|
empty.isHidden = matches
|
||||||
}
|
}
|
||||||
|
|
||||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
||||||
switch selector {
|
switch selector {
|
||||||
case #selector(NSResponder.insertNewline(_:)):
|
case #selector(NSResponder.insertNewline(_:)):
|
||||||
if !action.isHidden { chooseAction() }
|
if !row.isHidden { chooseAction() }
|
||||||
case #selector(NSResponder.cancelOperation(_:)):
|
case #selector(NSResponder.cancelOperation(_:)): close()
|
||||||
close()
|
case #selector(NSResponder.moveUp(_:)), #selector(NSResponder.moveDown(_:)): return true
|
||||||
case #selector(NSResponder.moveUp(_:)), #selector(NSResponder.moveDown(_:)):
|
|
||||||
return true // There is currently one contextual action.
|
|
||||||
default: return false
|
default: return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
actions.closed = { [weak self] in
|
actions.closed = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.openButton.keyEquivalent = "\r"
|
||||||
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
|
if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) }
|
||||||
else { self.dismiss(restoringFocus: false) }
|
else { self.dismiss(restoringFocus: false) }
|
||||||
}
|
}
|
||||||
@@ -143,7 +144,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func windowDidResignKey(_ notification: Notification) {
|
func windowDidResignKey(_ notification: Notification) {
|
||||||
guard !isLaunching, !actions.isShown, window?.attachedSheet == nil else { return }
|
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||||
dismiss(restoringFocus: false)
|
dismiss(restoringFocus: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +496,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
|||||||
let application = results[tableView.selectedRow]
|
let application = results[tableView.selectedRow]
|
||||||
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
|
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
|
||||||
actions.show(for: application, relativeTo: actionsButton)
|
actions.show(for: application, relativeTo: actionsButton)
|
||||||
|
openButton.keyEquivalent = actions.isShown ? "" : "\r"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reviewUninstall(_ application: Application) {
|
private func reviewUninstall(_ application: Application) {
|
||||||
@@ -613,7 +615,7 @@ final class LauncherRowView: NSTableRowView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@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 size = NSSize(width: radius * 2 + 1, height: radius * 2 + 1)
|
||||||
let mask = NSImage(size: size, flipped: false) { rect in
|
let mask = NSImage(size: size, flipped: false) { rect in
|
||||||
NSColor.black.setFill()
|
NSColor.black.setFill()
|
||||||
|
|||||||
Reference in New Issue
Block a user