diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index 1299b112..bd811042 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -55,6 +55,10 @@ extension AXUIElement { return attribute(kAXHiddenAttribute, Bool.self) == true } + func isFullScreen() -> Bool { + return attribute(kAXFullscreenAttribute, Bool.self) == true + } + func focusedWindow() -> AXUIElement? { return attribute(kAXFocusedWindowAttribute, AXUIElement.self) } @@ -67,6 +71,21 @@ extension AXUIElement { return attribute(kAXSubroleAttribute, String.self) } + func closeButton() -> AXUIElement { + return attribute(kAXCloseButtonAttribute, AXUIElement.self)! + } + + func closeWindow() { + if isFullScreen() { + AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, 0 as CFTypeRef) + } + AXUIElementPerformAction(closeButton(), kAXPressAction as CFString) + } + + func focusWindow() { + AXUIElementPerformAction(self, kAXRaiseAction as CFString) + } + func subscribeWithRetry(_ axObserver: AXObserver, _ notification: String, _ pointer: UnsafeMutableRawPointer?, _ callback: (() -> Void)? = nil, _ runningApplication: NSRunningApplication? = nil, _ wid: CGWindowID? = nil, _ attemptsCount: Int = 0) { DispatchQueue.global(qos: .userInteractive).async { [weak self] () -> () in guard let self = self else { return } diff --git a/src/api-wrappers/PrivateApis.swift b/src/api-wrappers/PrivateApis.swift index 23937cc1..a1898d1f 100644 --- a/src/api-wrappers/PrivateApis.swift +++ b/src/api-wrappers/PrivateApis.swift @@ -132,6 +132,8 @@ func CGSGetWindowLevel(_ cid: CGSConnectionID, _ wid: CGWindowID, _ level: inout @_silgen_name("SLSRequestScreenCaptureAccess") @discardableResult func SLSRequestScreenCaptureAccess() -> UInt8 +// for some reason, this attribute is missing from AXAttributeConstants +let kAXFullscreenAttribute = "AXFullScreen" diff --git a/src/logic/DispatchQueues.swift b/src/logic/DispatchQueues.swift index 02e89dea..e8b7a0f0 100644 --- a/src/logic/DispatchQueues.swift +++ b/src/logic/DispatchQueues.swift @@ -2,6 +2,6 @@ import Foundation // OS events should be observed on background threads class DispatchQueues { - static let focusActions = DispatchQueue(label: "focusActions", qos: .userInteractive) + static let accessibilityCommands = DispatchQueue(label: "accessibilityCommands", qos: .userInteractive) static let keyboardEvents = DispatchQueue(label: "keyboardEvents", qos: .userInteractive) } diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index 22f4bd0b..012c43d6 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -16,6 +16,7 @@ class Preferences { "nextWindowShortcut": "⇥", "previousWindowShortcut": "⇧⇥", "cancelShortcut": "⎋", + "closeWindowShortcut": "W", "arrowKeysEnabled": true, "mouseHoverEnabled": true, "showMinimizedWindows": true, @@ -51,6 +52,7 @@ class Preferences { static var nextWindowShortcut: String { defaults.string(forKey: "nextWindowShortcut")! } static var previousWindowShortcut: String { defaults.string(forKey: "previousWindowShortcut")! } static var cancelShortcut: String { defaults.string(forKey: "cancelShortcut")! } + static var closeWindowShortcut: String { defaults.string(forKey: "closeWindowShortcut")! } static var arrowKeysEnabled: Bool { defaults.bool(forKey: "arrowKeysEnabled") } static var mouseHoverEnabled: Bool { defaults.bool(forKey: "mouseHoverEnabled") } static var showMinimizedWindows: Bool { defaults.bool(forKey: "showMinimizedWindows") } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index ea8d092a..ef84d15d 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -64,13 +64,17 @@ class Window { thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) } + func close() { + DispatchQueues.accessibilityCommands.async { [weak self] in + self?.axUiElement.closeWindow() + } + } + func focus() { - // implementation notes: the following sequence of actions repeats some calls. This is necessary for - // minimized windows on other spaces, and focuses windows faster (e.g. the Security & Privacy window) // macOS bug: when switching to a System Preferences window in another space, it switches to that space, // but quickly switches back to another window in that space // You can reproduce this buggy behaviour by clicking on the dock icon, proving it's an OS bug - DispatchQueues.focusActions.async { [weak self] in + DispatchQueues.accessibilityCommands.async { [weak self] in guard let self = self else { return } var elementConnection = UInt32(0) CGSGetWindowOwner(cgsMainConnectionId, self.cgWindowId, &elementConnection) @@ -78,7 +82,7 @@ class Window { CGSGetConnectionPSN(elementConnection, &psn) _SLPSSetFrontProcessWithOptions(&psn, self.cgWindowId, .userGenerated) self.makeKeyWindow(psn) - AXUIElementPerformAction(self.axUiElement, kAXRaiseAction as CFString) + self.axUiElement.focusWindow() } } diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index b4f00cb3..19011d0d 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -31,7 +31,7 @@ class Windows { } static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Int) { - if focusedWindowIndex >= destroyedWindowIndex { + if focusedWindowIndex > destroyedWindowIndex { cycleFocusedWindowIndex(-1) } } diff --git a/src/ui/App.swift b/src/ui/App.swift index 82004521..d0e6b0bb 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -87,6 +87,10 @@ class App: NSApplication, NSApplicationDelegate { isFirstSummon = true } + func closeSelectedWindow() { + Windows.focusedWindow()?.close() + } + func focusTarget() { debugPrint("focusTarget") focusSelectedWindow(Windows.focusedWindow()) diff --git a/src/ui/preferences-window/tabs/GeneralTab.swift b/src/ui/preferences-window/tabs/GeneralTab.swift index d4c1bddc..124100ea 100644 --- a/src/ui/preferences-window/tabs/GeneralTab.swift +++ b/src/ui/preferences-window/tabs/GeneralTab.swift @@ -17,6 +17,7 @@ class GeneralTab: NSViewController, PreferencePane { let nextWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select next window", comment: ""), "nextWindowShortcut", Preferences.nextWindowShortcut, labelPosition: .right) let previousWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Select previous window", comment: ""), "previousWindowShortcut", Preferences.previousWindowShortcut, labelPosition: .right) let cancelShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Cancel and hide", comment: ""), "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right) + let closeWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Close window", comment: ""), "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right) let enableArrows = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Arrow keys", comment: ""), "arrowKeysEnabled", extraAction: GeneralTab.arrowKeysEnabledCallback, labelPosition: .right) let enableMouse = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Mouse hover", comment: ""), "mouseHoverEnabled", labelPosition: .right) let holdAndPress = StackView(holdShortcut) @@ -27,7 +28,7 @@ class GeneralTab: NSViewController, PreferencePane { let screensToShow = dropdown("screensToShow", ScreensToShowPreference.allCases) let showMinimizedWindows = StackView(LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Minimized", comment: ""), "showMinimizedWindows", labelPosition: .right)) let showHiddenWindows = StackView(LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hidden", comment: ""), "showHiddenWindows", labelPosition: .right)) - let shortcuts = StackView([nextWindowShortcut, previousWindowShortcut, cancelShortcut].map { StackView($0) }, .vertical) + let shortcuts = StackView([nextWindowShortcut, previousWindowShortcut, cancelShortcut, closeWindowShortcut].map { (view: [NSView]) in StackView(view) }, .vertical) let toShowDropdowns = StackView([appsToShow, spacesToShow, screensToShow], .vertical) let toShowCheckboxes = StackView([showMinimizedWindows, showHiddenWindows], .vertical) let toShowExplanations = LabelAndControl.makeLabel(NSLocalizedString("Show the following windows:", comment: "")) @@ -43,7 +44,7 @@ class GeneralTab: NSViewController, PreferencePane { [1, 2, 3].forEach { grid.row(at: $0).topPadding = GridView.interPadding } grid.fit() - GeneralTab.shortcutsDependentOnHoldShortcut.append(contentsOf: [enableArrows[0] as! NSControl] + [nextWindowShortcut, previousWindowShortcut, cancelShortcut].map { $0[0] as! NSControl }) + GeneralTab.shortcutsDependentOnHoldShortcut.append(contentsOf: [enableArrows[0] as! NSControl] + [nextWindowShortcut, previousWindowShortcut, cancelShortcut, closeWindowShortcut].map { $0[0] as! NSControl }) GeneralTab.arrowKeysEnabledCallback(enableArrows[0] as! NSControl) startAtLoginCallback(startAtLogin[1] as! NSControl) @@ -58,9 +59,9 @@ class GeneralTab: NSViewController, PreferencePane { private static func addShortcut(_ fn: @escaping () -> Void, _ type: KeyEventType, _ shortcut: Shortcut, _ controlId: String) { removeShortcutIfExists(controlId, type) // remove the previous shortcut shortcutActions[controlId] = ShortcutAction(shortcut: shortcut, actionHandler: { _ in - let shortcutThatInitiatesTheApp = controlId == "previousWindowShortcut" || controlId == "nextWindowShortcut" - App.app.uiWorkShouldBeDone = shortcutThatInitiatesTheApp - if shortcutThatInitiatesTheApp { + let shortcutsWithUiShown = ["previousWindowShortcut", "nextWindowShortcut", "closeWindowShortcut"].contains(controlId) + App.app.uiWorkShouldBeDone = shortcutsWithUiShown + if shortcutsWithUiShown { App.app.appIsBeingUsed = true DispatchQueue.main.async { () -> () in fn() } } else if App.app.appIsBeingUsed { @@ -83,16 +84,24 @@ class GeneralTab: NSViewController, PreferencePane { removeShortcutIfExists(controlId, .down) return } - if controlId == "nextWindowShortcut" { - addShortcut({ App.app.showUiOrCycleSelection(1) }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + newValue)!, controlId) - } else if controlId == "previousWindowShortcut" { - addShortcut({ App.app.showUiOrCycleSelection(-1) }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + newValue)!, controlId) - } else if controlId == "cancelShortcut" { - addShortcut({ App.app.hideUi() }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + newValue)!, controlId) - } + let action = shortcutAction(controlId, newValue) + addShortcut(action, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + newValue)!, controlId) } } + private static func shortcutAction(_ controlId: String, _ newValue: String) -> () -> Void { + if controlId == "nextWindowShortcut" { + return { App.app.showUiOrCycleSelection(1) } + } else if controlId == "previousWindowShortcut" { + return { App.app.showUiOrCycleSelection(-1) } + } else if controlId == "cancelShortcut" { + return { App.app.hideUi() } + } else if controlId == "closeWindowShortcut" { + return { App.app.closeSelectedWindow() } + } + return {} + } + @objc static func arrowKeysEnabledCallback(_ sender: NSControl) { if (sender as! NSButton).state == .on { addShortcut({ App.app.cycleSelection(1) }, .down, Shortcut(keyEquivalent: Preferences.holdShortcut + "→")!, "→")