diff --git a/README.md b/README.md index 49780a1..e831e76 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift index f8853fc..48b49c5 100644 --- a/Sources/Conjure/ApplicationActionsController.swift +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -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 diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index fff8483..92e4ba9 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -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()