fix: morph launcher into uninstall review and align its filter
This commit is contained in:
@@ -21,6 +21,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
private let actionsButton = NSButton(title: "Actions ⌘K", target: nil, action: nil)
|
||||
private let actions = ApplicationActionsController()
|
||||
private var uninstallReview: UninstallReviewController?
|
||||
private var launcherViews: [NSView] = []
|
||||
private var launcherFrameBeforeReview: NSRect?
|
||||
|
||||
init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) {
|
||||
self.history = history
|
||||
@@ -46,7 +48,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
window.delegate = self
|
||||
window.navigateSelection = { [weak self] offset in
|
||||
guard let self else { return }
|
||||
if self.actions.isShown { self.actions.moveSelection(by: offset) }
|
||||
if let review = self.uninstallReview { review.moveSelection(offset) }
|
||||
else if self.actions.isShown { self.actions.moveSelection(by: offset) }
|
||||
else { self.moveSelection(by: offset) }
|
||||
}
|
||||
window.dismiss = { [weak self] in self?.dismiss() }
|
||||
@@ -69,6 +72,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
func present() {
|
||||
if launcherFrameBeforeReview != nil {
|
||||
showWindow(nil)
|
||||
NSApp.activate()
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
return
|
||||
}
|
||||
let frontmost = NSWorkspace.shared.frontmostApplication
|
||||
if frontmost?.processIdentifier != ProcessInfo.processInfo.processIdentifier {
|
||||
previousApplication = frontmost
|
||||
@@ -96,6 +105,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
|
||||
func dismiss(restoringFocus: Bool = true) {
|
||||
guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return }
|
||||
if let review = uninstallReview { review.goBack(); return }
|
||||
guard launcherFrameBeforeReview == nil else { return }
|
||||
actions.close()
|
||||
alignmentGuides?.end()
|
||||
let previous = previousApplication
|
||||
@@ -108,7 +119,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
private func savePosition() {
|
||||
guard let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return }
|
||||
guard launcherFrameBeforeReview == nil, let window, let screen = window.screen, let identifier = displayIdentifier(screen) else { return }
|
||||
UserDefaults.standard.set([
|
||||
"display": identifier, "x": window.frame.minX, "y": window.frame.minY
|
||||
], forKey: Self.positionKey)
|
||||
@@ -148,7 +159,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
guard !isLaunching, window?.attachedSheet == nil else { return }
|
||||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil else { return }
|
||||
dismiss(restoringFocus: false)
|
||||
}
|
||||
|
||||
@@ -323,6 +334,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
actionPill.layer?.backgroundColor = SunsetPalette.accent.withAlphaComponent(0.08).cgColor
|
||||
}
|
||||
window.initialFirstResponder = searchField
|
||||
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
|
||||
}
|
||||
|
||||
private func refreshApplications() {
|
||||
@@ -416,7 +428,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
private func updateOpenButton() {
|
||||
openButton.isEnabled = !isLaunching && results.indices.contains(tableView.selectedRow)
|
||||
openButton.isEnabled = !isLaunching && launcherFrameBeforeReview == nil && results.indices.contains(tableView.selectedRow)
|
||||
actionsButton.isEnabled = openButton.isEnabled
|
||||
}
|
||||
|
||||
@@ -496,7 +508,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
@objc private func showActions() {
|
||||
guard !isLaunching, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
|
||||
guard !isLaunching, launcherFrameBeforeReview == nil, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return }
|
||||
let application = results[tableView.selectedRow]
|
||||
actions.open = { [weak self] in self?.openApplication(application) }
|
||||
actions.uninstall = { [weak self] in self?.reviewUninstall(application) }
|
||||
@@ -505,12 +517,13 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
}
|
||||
|
||||
private func reviewUninstall(_ application: Application) {
|
||||
guard let window, window.attachedSheet == nil else { return }
|
||||
guard let window, let content = window.contentView, launcherFrameBeforeReview == nil, window.attachedSheet == nil else { return }
|
||||
let review = UninstallReviewController(application: application, uninstaller: uninstaller)
|
||||
uninstallReview = review
|
||||
review.closed = { [weak self] in
|
||||
self?.uninstallReview = nil
|
||||
self?.window?.makeFirstResponder(self?.searchField)
|
||||
launcherFrameBeforeReview = window.frame
|
||||
review.closed = { [weak self, weak review] in
|
||||
guard let self, let review else { return }
|
||||
self.restoreLauncher(from: review)
|
||||
}
|
||||
review.changed = { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -518,7 +531,50 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
|
||||
self.discoveryTask = nil
|
||||
self.refreshApplications()
|
||||
}
|
||||
review.present(on: window)
|
||||
review.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
review.view.alphaValue = 0
|
||||
content.addSubview(review.view)
|
||||
NSLayoutConstraint.activate([
|
||||
review.view.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
review.view.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
review.view.topAnchor.constraint(equalTo: content.topAnchor),
|
||||
review.view.bottomAnchor.constraint(equalTo: content.bottomAnchor)
|
||||
])
|
||||
for view in launcherViews { view.setAccessibilityHidden(true) }
|
||||
openButton.keyEquivalent = ""
|
||||
updateOpenButton()
|
||||
var frame = window.frame
|
||||
frame.size.height = min(540, window.screen?.visibleFrame.height ?? 540)
|
||||
frame.origin.y = window.frame.maxY - frame.height
|
||||
if let screen = window.screen { frame.origin.y = max(frame.origin.y, screen.visibleFrame.minY) }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.18
|
||||
window.animator().setFrame(frame, display: true)
|
||||
for view in launcherViews { view.animator().alphaValue = 0 }
|
||||
review.view.animator().alphaValue = 1
|
||||
}
|
||||
review.present()
|
||||
}
|
||||
|
||||
private func restoreLauncher(from review: UninstallReviewController) {
|
||||
guard let window, let frame = launcherFrameBeforeReview, uninstallReview === review else { return }
|
||||
uninstallReview = nil
|
||||
for view in launcherViews { view.setAccessibilityHidden(false) }
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : 0.18
|
||||
window.animator().setFrame(frame, display: true)
|
||||
for view in launcherViews { view.animator().alphaValue = 1 }
|
||||
review.view.animator().alphaValue = 0
|
||||
} completionHandler: { [weak self] in
|
||||
MainActor.assumeIsolated {
|
||||
review.view.removeFromSuperview()
|
||||
guard let self else { return }
|
||||
self.launcherFrameBeforeReview = nil
|
||||
self.openButton.keyEquivalent = "\r"
|
||||
self.updateOpenButton()
|
||||
self.window?.makeFirstResponder(self.searchField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func openSelection() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import AppKit
|
||||
import ConjureKit
|
||||
|
||||
@MainActor
|
||||
final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
||||
final class UninstallReviewController: NSViewController, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
||||
private let application: Application
|
||||
private let uninstaller: ApplicationUninstaller
|
||||
private let search = NSSearchField()
|
||||
@@ -15,31 +15,23 @@ final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSS
|
||||
private var selected: Set<URL> = []
|
||||
private var busy = false
|
||||
private var scanTask: Task<Void, Never>?
|
||||
private var window: NSWindow? { view.window }
|
||||
var closed: (() -> Void)?
|
||||
var changed: (() -> Void)?
|
||||
|
||||
init(application: Application, uninstaller: ApplicationUninstaller) {
|
||||
self.application = application
|
||||
self.uninstaller = uninstaller
|
||||
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 540),
|
||||
styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false)
|
||||
window.title = "Uninstall \(application.name)"
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.backgroundColor = .clear
|
||||
window.isOpaque = false
|
||||
window.isReleasedWhenClosed = false
|
||||
super.init(window: window)
|
||||
window.delegate = self
|
||||
configure(window)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
configure()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates uninstall review programmatically") }
|
||||
|
||||
func present(on parent: NSWindow) {
|
||||
func present() {
|
||||
guard let window else { return }
|
||||
parent.beginSheet(window)
|
||||
window.makeFirstResponder(search)
|
||||
(search.currentEditor() as? NSTextView)?.insertionPointColor = SunsetPalette.accent
|
||||
scanTask = Task { [weak self, application] in
|
||||
do {
|
||||
let found = try await Task.detached(priority: .userInitiated) {
|
||||
@@ -56,21 +48,22 @@ final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSS
|
||||
}
|
||||
}
|
||||
|
||||
private func configure(_ window: NSWindow) {
|
||||
let content = NSVisualEffectView()
|
||||
content.material = .hudWindow
|
||||
content.blendingMode = .behindWindow
|
||||
content.state = .active
|
||||
window.contentView = content
|
||||
let background = LauncherBackground(frame: content.bounds)
|
||||
background.autoresizingMask = [.width, .height]
|
||||
background.setAccessibilityElement(false)
|
||||
content.addSubview(background)
|
||||
search.placeholderString = "Filter files and folders by name or path…"
|
||||
private func configure() {
|
||||
let content = UninstallReviewContent()
|
||||
content.wantsLayer = true
|
||||
view = content
|
||||
search.placeholderAttributedString = NSAttributedString(
|
||||
string: "Filter files and folders by name or path…",
|
||||
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: NSFont.systemFont(ofSize: 20)]
|
||||
)
|
||||
if let cell = search.cell as? NSSearchFieldCell {
|
||||
cell.searchButtonCell = nil
|
||||
cell.cancelButtonCell = nil
|
||||
}
|
||||
search.delegate = self
|
||||
search.sendsSearchStringImmediately = true
|
||||
search.setAccessibilityLabel("Filter uninstall files")
|
||||
search.font = .systemFont(ofSize: 18)
|
||||
search.font = .systemFont(ofSize: 20)
|
||||
search.textColor = SunsetPalette.text
|
||||
search.isBordered = false
|
||||
search.drawsBackground = false
|
||||
@@ -80,7 +73,16 @@ final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSS
|
||||
status.setAccessibilityLabel("Uninstall selection and progress")
|
||||
back.target = self
|
||||
back.action = #selector(goBack)
|
||||
back.bezelStyle = .rounded
|
||||
back.title = ""
|
||||
back.image = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil)
|
||||
back.imageScaling = .scaleProportionallyDown
|
||||
back.isBordered = false
|
||||
back.contentTintColor = SunsetPalette.secondaryText
|
||||
back.setAccessibilityLabel("Back to applications")
|
||||
let header = NSStackView(views: [back, search])
|
||||
header.orientation = .horizontal
|
||||
header.alignment = .centerY
|
||||
header.spacing = 12
|
||||
remove.target = self
|
||||
remove.action = #selector(confirmUninstall)
|
||||
remove.contentTintColor = .systemRed
|
||||
@@ -105,18 +107,21 @@ final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSS
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.drawsBackground = false
|
||||
scroll.contentView.drawsBackground = false
|
||||
let footer = NSStackView(views: [back, NSView(), remove])
|
||||
let footer = NSStackView(views: [NSView(), remove])
|
||||
footer.orientation = .horizontal
|
||||
footer.spacing = 12
|
||||
for view in [search, status, scroll, footer] {
|
||||
for view in [header, status, scroll, footer] {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(view)
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
search.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
|
||||
search.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20),
|
||||
search.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor, constant: 16),
|
||||
search.heightAnchor.constraint(equalToConstant: 32),
|
||||
header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16),
|
||||
header.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24),
|
||||
header.topAnchor.constraint(equalTo: content.topAnchor, constant: 20),
|
||||
header.heightAnchor.constraint(equalToConstant: 32),
|
||||
back.widthAnchor.constraint(equalToConstant: 22),
|
||||
back.heightAnchor.constraint(equalToConstant: 22),
|
||||
search.widthAnchor.constraint(equalTo: header.widthAnchor, constant: -34),
|
||||
status.leadingAnchor.constraint(equalTo: search.leadingAnchor),
|
||||
status.trailingAnchor.constraint(equalTo: search.trailingAnchor),
|
||||
status.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12),
|
||||
@@ -161,7 +166,7 @@ final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSS
|
||||
return true
|
||||
}
|
||||
|
||||
private func moveSelection(_ offset: Int) {
|
||||
func moveSelection(_ offset: Int) {
|
||||
guard !visible.isEmpty else { return }
|
||||
let row = min(max(table.selectedRow + offset, 0), visible.count - 1)
|
||||
table.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
|
||||
@@ -300,23 +305,32 @@ final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSS
|
||||
back.isEnabled = !value
|
||||
remove.isEnabled = !value && !selected.isEmpty
|
||||
table.reloadData()
|
||||
if !value { updateStatus() }
|
||||
if !value {
|
||||
updateStatus()
|
||||
window?.makeFirstResponder(search)
|
||||
}
|
||||
}
|
||||
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
if !busy { goBack() }
|
||||
return false
|
||||
}
|
||||
override func cancelOperation(_ sender: Any?) { goBack() }
|
||||
|
||||
@objc private func goBack() {
|
||||
guard !busy, let window, let parent = window.sheetParent else { return }
|
||||
@objc func goBack() {
|
||||
guard !busy else { return }
|
||||
scanTask?.cancel()
|
||||
parent.endSheet(window)
|
||||
window.orderOut(nil)
|
||||
(view as! UninstallReviewContent).acceptsInteraction = false
|
||||
view.setAccessibilityHidden(true)
|
||||
closed?()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class UninstallReviewContent: NSView {
|
||||
var acceptsInteraction = true
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
acceptsInteraction ? super.hitTest(point) : nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReviewError: LocalizedError {
|
||||
let errorDescription: String?
|
||||
init(_ message: String) { errorDescription = message }
|
||||
|
||||
Reference in New Issue
Block a user