diff --git a/README.md b/README.md index 61341a479..3d1a70056 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ From macOS 10.12 to 10.15 * You hover and click with the `🖱️ mouse`. * You cycle with `⇦ left arrow` and `⇨ right arrow`. +* You can close a window with `w` or quit the whole application with `q` * You can cancel with `⎋ escape`. ## Configuration diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index fab848a4a..a45e55dc4 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 05F4FB3C23BA5A890001427A /* Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F4FB3B23BA5A890001427A /* Observer.swift */; }; 4807A6C623A9CD190052A53E /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4807A6C523A9CD190052A53E /* SkyLight.framework */; }; D04BA02DD4152997C32CF50B /* StatusItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */; }; D04BA0496ACF1427B6E9D369 /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA78E3B4E73B40DB77174 /* CGWindow.swift */; }; @@ -35,6 +36,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 05F4FB3B23BA5A890001427A /* Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observer.swift; sourceTree = ""; }; 4807A6C523A9CD190052A53E /* SkyLight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SkyLight.framework; path = ../../../../System/Library/PrivateFrameworks/SkyLight.framework; sourceTree = ""; }; D04BA02F476DE30C4647886C /* PreferencesPanel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesPanel.swift; sourceTree = ""; }; D04BA0AF7C5DCF367FBB663C /* StatusItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItem.swift; sourceTree = ""; }; @@ -225,6 +227,7 @@ D04BA2D2AD6B1CCA3F3A4DD7 /* SystemPermissions.swift */, D04BA5EB5ED248C8C22CC672 /* Spaces.swift */, D04BAE80772D25834E440975 /* TrackedWindow.swift */, + 05F4FB3B23BA5A890001427A /* Observer.swift */, ); path = logic; sourceTree = ""; @@ -348,6 +351,7 @@ buildActionMask = 2147483647; files = ( D04BA960DDD1D32A3019C835 /* CollectionViewCenterFlowLayout.swift in Sources */, + 05F4FB3C23BA5A890001427A /* Observer.swift in Sources */, D04BAEF78503D7A2CEFB9E9E /* main.swift in Sources */, D04BA20D4A240843293B3B52 /* Cell.swift in Sources */, D04BA57A871B7269BEBAFF84 /* Keyboard.swift in Sources */, diff --git a/alt-tab-macos/api-wrappers/AXUIElement.swift b/alt-tab-macos/api-wrappers/AXUIElement.swift index ba65ac229..82bb99fa0 100644 --- a/alt-tab-macos/api-wrappers/AXUIElement.swift +++ b/alt-tab-macos/api-wrappers/AXUIElement.swift @@ -7,7 +7,9 @@ import Foundation enum AXAttributeKey: String { case windows = "AXWindows" case minimized = "AXMinimized" + case fullScreen = "AXFullScreen" case focusedWindow = "AXFocusedWindow" + case closeButton = "AXCloseButton" } extension AXUIElement { @@ -47,6 +49,10 @@ extension AXUIElement { return attribute(.minimized, Bool.self) == true } + func isFullScreen() -> Bool { + return attribute(.fullScreen, Bool.self) == true + } + func focus(_ id: CGWindowID) { var elementConnection = UInt32(0) CGSGetWindowOwner(cgsMainConnectionId, id, &elementConnection) @@ -80,4 +86,14 @@ extension AXUIElement { SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes1)).pointee)) SLPSPostEventRecordTo(&psn_, &(UnsafeMutablePointer(mutating: UnsafePointer(bytes2)).pointee)) } + + func close() { + if isFullScreen() { + AXUIElementSetAttributeValue(self, AXAttributeKey.fullScreen.rawValue as CFString, 0 as CFTypeRef) + return + } + if let closeButtonRef = attribute(.closeButton, AXUIElement.self) { + AXUIElementPerformAction(closeButtonRef, kAXPressAction as CFString) + } + } } diff --git a/alt-tab-macos/logic/Keyboard.swift b/alt-tab-macos/logic/Keyboard.swift index eef14de41..ebd2fc426 100644 --- a/alt-tab-macos/logic/Keyboard.swift +++ b/alt-tab-macos/logic/Keyboard.swift @@ -47,6 +47,8 @@ func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, let isMetaDown = event.modifierFlags.contains(Preferences.metaModifierFlag!) let isRightArrow = event.keyCode == kVK_RightArrow let isLeftArrow = event.keyCode == kVK_LeftArrow + let isQ = event.keyCode == kVK_ANSI_Q + let isW = event.keyCode == kVK_ANSI_W let isEscape = event.keyCode == kVK_Escape if isMetaDown && type == .keyDown { if isTab && event.modifierFlags.contains(.shift) { @@ -57,10 +59,14 @@ func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, return dispatchWork(application, true, { application.cycleSelection(1) }) } else if isLeftArrow && application.appIsBeingUsed { return dispatchWork(application, true, { application.cycleSelection(-1) }) + } else if isQ && application.appIsBeingUsed { + return dispatchWork(application, true, { application.quitTargetApp() }) + } else if isW && application.appIsBeingUsed { + return dispatchWork(application, true, { application.closeTarget() }) } else if type == .keyDown && isEscape { return dispatchWork(application, false, { application.hideUi() }) } - } else if isMetaChanged && !isMetaDown { + } else if isMetaChanged && !isMetaDown && application.appIsBeingUsed { return dispatchWork(application, false, { application.focusTarget() }) } } diff --git a/alt-tab-macos/logic/Observer.swift b/alt-tab-macos/logic/Observer.swift new file mode 100644 index 000000000..219054342 --- /dev/null +++ b/alt-tab-macos/logic/Observer.swift @@ -0,0 +1,54 @@ +import Cocoa +import Foundation + +enum AXNotification: String { + case destroyed = "AXUIElementDestroyed" + case rezized = "AXWindowResized" +} + +enum ObserverMode { + case refreshUiOnClose +} + +class Observer { + var axObserver: AXObserver? + + func createObserver(_ window: TrackedWindow, _ delegate: Application, _ mode: ObserverMode) { + let application = UnsafeMutableRawPointer(Unmanaged.passUnretained(delegate).toOpaque()) + + func refreshUiOnCloseCallback(observer: AXObserver, element: AXUIElement, notificationName: CFString, delegate_: UnsafeMutableRawPointer?) -> Void { + debugPrint("refreshUiOnCloseCallback") + let application = Unmanaged.fromOpaque(delegate_!).takeUnretainedValue() + + if notificationName == AXNotification.rezized.rawValue as CFString { + element.close() + AXObserverRemoveNotification(observer, element, notificationName) + } + if notificationName == AXNotification.destroyed.rawValue as CFString { + if application.appIsBeingUsed { + // give the system a ms to clean up + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(1), execute: { + application.isOutdated = true + application.showUiOrCycleSelection(TrackedWindows.focusedWindowIndex) + }) + } + AXObserverRemoveNotification(observer, element, notificationName) + } + } + + if mode == .refreshUiOnClose { + if AXObserverCreate(window.ownerPid, refreshUiOnCloseCallback, &axObserver) == .success { + if (window.axWindow?.isFullScreen())! { + if AXObserverAddNotification(axObserver!, window.axWindow!, AXNotification.rezized.rawValue as CFString, application) != .success { + AXObserverRemoveNotification(axObserver!, window.axWindow!, AXNotification.rezized.rawValue as CFString) + } + } + if AXObserverAddNotification(axObserver!, window.axWindow!, AXNotification.destroyed.rawValue as CFString, application) != .success { + AXObserverRemoveNotification(axObserver!, window.axWindow!, AXNotification.destroyed.rawValue as CFString) + } + } + } + + CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(axObserver!), .defaultMode) + } +} diff --git a/alt-tab-macos/logic/TrackedWindow.swift b/alt-tab-macos/logic/TrackedWindow.swift index 5e937de6b..592d03ddf 100644 --- a/alt-tab-macos/logic/TrackedWindow.swift +++ b/alt-tab-macos/logic/TrackedWindow.swift @@ -9,7 +9,18 @@ class TrackedWindow { var thumbnail: NSImage? var icon: NSImage? var app: NSRunningApplication? - var axWindow: AXUIElement? + private var _axWindow: AXUIElement? = nil + var axWindow: AXUIElement? { + set { + _axWindow = newValue + } + get { + if _axWindow == nil { + _axWindow = id.AXUIElementOfOtherSpaceWindow(ownerPid) + } + return _axWindow + } + } var isMinimized: Bool var spaceId: CGSSpaceID? var spaceIndex: SpaceIndex? @@ -28,7 +39,7 @@ class TrackedWindow { if let cgImage = cgId.screenshot() { self.thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) } - self.axWindow = axWindow + self._axWindow = axWindow self.isMinimized = isMinimized self.spaceId = spaceId // System Preferences windows appear on all spaces, so we make them the current space @@ -37,9 +48,16 @@ class TrackedWindow { } func focus() { - if axWindow == nil { - axWindow = id.AXUIElementOfOtherSpaceWindow(ownerPid) - } axWindow?.focus(id) } + + func close() { + axWindow?.close() + } + + func quitApp() { + if app != nil { + app?.terminate() + } + } } diff --git a/alt-tab-macos/ui/Application.swift b/alt-tab-macos/ui/Application.swift index 9c96e3f75..8828b9ef2 100644 --- a/alt-tab-macos/ui/Application.swift +++ b/alt-tab-macos/ui/Application.swift @@ -5,11 +5,13 @@ let cgsMainConnectionId = CGSMainConnectionID() class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { static let name = "AltTab" + let observer = Observer() var statusItem: NSStatusItem? var thumbnailsPanel: ThumbnailsPanel? var preferencesPanel: PreferencesPanel? var uiWorkShouldBeDone = true var isFirstSummon = true + var isOutdated = false var appIsBeingUsed = false override init() { @@ -50,10 +52,17 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { func focusTarget() { debugPrint("focusTarget") - if appIsBeingUsed { - debugPrint("focusTarget: appIsBeingUsed") - focusSelectedWindow(TrackedWindows.focusedWindow()) - } + focusSelectedWindow(TrackedWindows.focusedWindow()) + } + + func closeTarget() { + debugPrint("closeTarget") + closeSelectedWindow(TrackedWindows.focusedWindow()) + } + + func quitTargetApp() { + debugPrint("quitTargetApp") + quitApplicationOfSelectedWindow(TrackedWindows.focusedWindow()) } @objc @@ -72,9 +81,10 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { func showUiOrCycleSelection(_ step: Int) { debugPrint("showUiOrCycleSelection", step) appIsBeingUsed = true - if isFirstSummon { - debugPrint("showUiOrCycleSelection: isFirstSummon") + if isFirstSummon || isOutdated { + debugPrint("showUiOrCycleSelection: isFirstSummon", isFirstSummon, "isOutdated", isOutdated) isFirstSummon = false + isOutdated = false TrackedWindows.refreshList(step) if TrackedWindows.list.count == 0 { return @@ -93,4 +103,14 @@ class Application: NSApplication, NSApplicationDelegate, NSWindowDelegate { hideUi() DispatchQueue.global(qos: .userInteractive).async { window?.focus() } } + + func closeSelectedWindow(_ window: TrackedWindow?) { + observer.createObserver(window!, self, .refreshUiOnClose) + DispatchQueue.global(qos: .userInteractive).async { window?.close() } + } + + func quitApplicationOfSelectedWindow(_ window: TrackedWindow?) { + observer.createObserver(window!, self, .refreshUiOnClose) + DispatchQueue.global(qos: .userInteractive).async { window?.quitApp() } + } }