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.
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.
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
@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()