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
View File
@@ -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()