diff --git a/alt-tab-macos/api-wrappers/AXUIElement.swift b/alt-tab-macos/api-wrappers/AXUIElement.swift index 86f687f1..a079218e 100644 --- a/alt-tab-macos/api-wrappers/AXUIElement.swift +++ b/alt-tab-macos/api-wrappers/AXUIElement.swift @@ -49,15 +49,26 @@ extension AXUIElement { return attribute(.windows, [AXUIElement].self) } + func window(_ id: CGWindowID) -> AXUIElement? { + return windows()?.first(where: { return id == $0.cgId() }) + } + func isMinimized() -> Bool { return attribute(.minimized, Bool.self) == true } func focus(_ id: CGWindowID) { + // 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 var elementConnection = UInt32(0) CGSGetWindowOwner(cgsMainConnectionId, id, &elementConnection) var psn = ProcessSerialNumber() CGSGetConnectionPSN(elementConnection, &psn) + AXUIElementPerformAction(self, kAXRaiseAction as CFString) + makeKeyWindow(psn, id) _SLPSSetFrontProcessWithOptions(&psn, id, .userGenerated) makeKeyWindow(psn, id) AXUIElementPerformAction(self, kAXRaiseAction as CFString) diff --git a/alt-tab-macos/api-wrappers/CGWindowID.swift b/alt-tab-macos/api-wrappers/CGWindowID.swift index 066ec8c6..8ce5101c 100644 --- a/alt-tab-macos/api-wrappers/CGWindowID.swift +++ b/alt-tab-macos/api-wrappers/CGWindowID.swift @@ -2,13 +2,13 @@ import Cocoa import Foundation extension CGWindowID { - func AXUIElement(_ ownerPid: pid_t) -> AXUIElement? { - return AXUIElementCreateApplication(ownerPid).windows()?.first(where: { return $0.cgId() == self }) + func AXUIElementApplication(_ ownerPid: pid_t) -> AXUIElement { + return AXUIElementCreateApplication(ownerPid) } - func AXUIElementOfOtherSpaceWindow(_ ownerPid: pid_t) -> AXUIElement? { + func AXUIElementOfOtherSpaceWindow(_ axApp: AXUIElement) -> AXUIElement? { CGSAddWindowsToSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId]) - let axWindow = AXUIElement(ownerPid) + let axWindow = axApp.window(self) CGSRemoveWindowsFromSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId]) return axWindow } diff --git a/alt-tab-macos/logic/TrackedWindow.swift b/alt-tab-macos/logic/TrackedWindow.swift index 5e937de6..cb35fe5e 100644 --- a/alt-tab-macos/logic/TrackedWindow.swift +++ b/alt-tab-macos/logic/TrackedWindow.swift @@ -3,42 +3,44 @@ import Foundation class TrackedWindow { var cgWindow: CGWindow - var ownerPid: pid_t var id: CGWindowID var title: String var thumbnail: NSImage? var icon: NSImage? - var app: NSRunningApplication? + var app: NSRunningApplication + var axApp: AXUIElement var axWindow: AXUIElement? + var isHidden: Bool var isMinimized: Bool var spaceId: CGSSpaceID? var spaceIndex: SpaceIndex? var rank: WindowRank? - init(_ cgWindow: CGWindow, _ cgId: CGWindowID, _ ownerPid: pid_t, _ isMinimized: Bool, _ axWindow: AXUIElement?, _ spaceId: CGSSpaceID?, _ spaceIndex: SpaceIndex?, _ rank: WindowRank?) { + init(_ cgWindow: CGWindow, _ cgId: CGWindowID, _ app: NSRunningApplication, _ axApp: AXUIElement, _ isHidden: Bool, _ isMinimized: Bool, _ axWindow: AXUIElement?, _ spaceId: CGSSpaceID?, _ spaceIndex: SpaceIndex?, _ rank: WindowRank?) { self.cgWindow = cgWindow self.id = cgId - self.ownerPid = ownerPid let cgTitle = cgWindow.value(.name, String.self) let cgOwnerName = cgWindow.value(.ownerName, String.self) // for some reason Google Chrome uses a unicode 0-width no-break space character in their empty window title self.title = cgTitle != nil && cgTitle != "" && cgTitle != "" ? cgTitle! : cgOwnerName ?? "" - self.app = NSRunningApplication(processIdentifier: ownerPid) - self.icon = self.app?.icon + self.app = app + self.axApp = axApp + self.icon = self.app.icon if let cgImage = cgId.screenshot() { self.thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) } self.axWindow = axWindow + self.isHidden = isHidden self.isMinimized = isMinimized self.spaceId = spaceId - // System Preferences windows appear on all spaces, so we make them the current space - self.spaceIndex = app?.bundleIdentifier == "com.apple.systempreferences" ? Spaces.currentSpaceIndex : spaceIndex + self.spaceIndex = spaceIndex self.rank = rank } func focus() { - if axWindow == nil { - axWindow = id.AXUIElementOfOtherSpaceWindow(ownerPid) + let onCurrentSpace = axWindow != nil + if !onCurrentSpace { + axWindow = id.AXUIElementOfOtherSpaceWindow(axApp) } axWindow?.focus(id) } diff --git a/alt-tab-macos/logic/TrackedWindows.swift b/alt-tab-macos/logic/TrackedWindows.swift index 7718ac31..212cf912 100644 --- a/alt-tab-macos/logic/TrackedWindows.swift +++ b/alt-tab-macos/logic/TrackedWindows.swift @@ -35,7 +35,7 @@ class TrackedWindows { Spaces.singleSpace = true } - private static func mapWindowsWithRankAndSpace(_ spaces: [(CGSSpaceID, SpaceIndex)]) -> [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)] { + private static func mapWindowsWithRankAndSpace(_ spaces: [(CGSSpaceID, SpaceIndex)]) -> WindowsMap { var windowSpaceMap: [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank?)] = [:] for (spaceId, spaceIndex) in spaces { Spaces.windowsInSpaces([spaceId]).forEach { @@ -45,7 +45,7 @@ class TrackedWindows { Spaces.windowsInSpaces(spaces.map { $0.0 }).enumerated().forEach { windowSpaceMap[$0.element]!.2 = $0.offset } - return windowSpaceMap as! [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)] + return windowSpaceMap as! WindowsMap } private static func sortList() { @@ -60,31 +60,44 @@ class TrackedWindows { }) } - private static func filterAndAddToList(_ windowsMap: [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)]) { + private static func filterAndAddToList(_ windowsMap: WindowsMap) { + // order and short-circuit of checks in this method is important for performance for cgWindow in CGWindow.windows(.optionAll) { guard let cgId = cgWindow.value(.number, CGWindowID.self), let ownerPid = cgWindow.value(.ownerPID, pid_t.self), + let app = NSRunningApplication(processIdentifier: ownerPid), cgWindow.isNotMenubarOrOthers(), cgWindow.isReasonablyBig() else { continue } + let axApp = cgId.AXUIElementApplication(ownerPid) let (spaceId, spaceIndex, rank) = windowsMap[cgId] ?? (nil, nil, nil) - if let axWindow = cgId.AXUIElement(ownerPid), axWindow.isActualWindow() { - // window is in the current space - if spaceId != nil { - list.append(TrackedWindow(cgWindow, cgId, ownerPid, false, axWindow, spaceId, spaceIndex, rank)) - } - // window is minimized - else if axWindow.isMinimized() { - list.append(TrackedWindow(cgWindow, cgId, ownerPid, true, axWindow, nil, nil, rank)) - } - } - // window is on another space - else if spaceId != nil && spaceId != Spaces.currentSpaceId { - list.append(TrackedWindow(cgWindow, cgId, ownerPid, false, nil, spaceId, spaceIndex, rank)) + if let (isMinimized, isHidden, axWindow) = filter(cgId, spaceId, app, axApp) { + list.append(TrackedWindow(cgWindow, cgId, app, axApp, isHidden, isMinimized, axWindow, spaceId, spaceIndex, rank)) } } } + + private static func filter(_ cgId: CGWindowID, _ spaceId: CGSSpaceID?, _ app: NSRunningApplication, _ axApp: AXUIElement) -> (Bool, Bool, AXUIElement?)? { + // window is in another space + if spaceId != nil && spaceId != Spaces.currentSpaceId { + return (false, false, nil) + } + // window is in the current space, or is hidden/minimized + if let axWindow = axApp.window(cgId), axWindow.isActualWindow() { + if spaceId != nil { + return (false, false, axWindow) + } + if app.isHidden { + return (axWindow.isMinimized(), true, axWindow) + } + if axWindow.isMinimized() { + return (true, false, axWindow) + } + } + return nil + } } typealias WindowRank = Int +typealias WindowsMap = [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)] \ No newline at end of file diff --git a/alt-tab-macos/ui/Cell.swift b/alt-tab-macos/ui/Cell.swift index 92f8b3ee..6bcb753f 100644 --- a/alt-tab-macos/ui/Cell.swift +++ b/alt-tab-macos/ui/Cell.swift @@ -9,6 +9,7 @@ class Cell: NSCollectionViewItem { var appIcon = NSImageView() var label = CellTitle(Preferences.fontHeight!) var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white) + var hiddenIcon = FontIcon(FontIcon.sfSymbolCircledDotSign, Preferences.fontIconSize, .white) var spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white) var openWindow: TrackedWindow? var mouseDownCallback: MouseDownCallback? @@ -50,13 +51,14 @@ class Cell: NSCollectionViewItem { label.string = element.title // workaround: setting string on NSTextView change the font (most likely a Cocoa bug) label.font = Preferences.font! - let fontIconWidth = Spaces.singleSpace && !openWindow!.isMinimized ? 0 : Preferences.fontIconSize + Preferences.interItemPadding - label.textContainer!.size.width = thumbnail.frame.width - Preferences.iconSize! - Preferences.interItemPadding - fontIconWidth + hiddenIcon.isHidden = !openWindow!.isHidden minimizedIcon.isHidden = !openWindow!.isMinimized - spaceIcon.isHidden = openWindow!.isMinimized || Spaces.singleSpace || Preferences.hideSpaceNumberLabels + spaceIcon.isHidden = element.spaceIndex == nil || Spaces.singleSpace || Preferences.hideSpaceNumberLabels if !spaceIcon.isHidden { spaceIcon.setNumber(UInt32(element.spaceIndex!)) } + let fontIconWidth = CGFloat([minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.interItemPadding) + label.textContainer!.size.width = thumbnail.frame.width - Preferences.iconSize! - Preferences.interItemPadding - fontIconWidth self.mouseDownCallback = mouseDownCallback self.mouseMovedCallback = mouseMovedCallback if view.trackingAreas.count > 0 { @@ -92,6 +94,7 @@ class Cell: NSCollectionViewItem { hStackView.spacing = Preferences.interItemPadding hStackView.addView(appIcon, in: .leading) hStackView.addView(label, in: .leading) + hStackView.addView(hiddenIcon, in: .leading) hStackView.addView(minimizedIcon, in: .leading) hStackView.addView(spaceIcon, in: .leading) return hStackView diff --git a/alt-tab-macos/ui/FontIcon.swift b/alt-tab-macos/ui/FontIcon.swift index 503105fa..13497fe4 100644 --- a/alt-tab-macos/ui/FontIcon.swift +++ b/alt-tab-macos/ui/FontIcon.swift @@ -4,6 +4,7 @@ import Cocoa // see https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ class FontIcon: CellTitle { static let sfSymbolCircledMinusSign = "􀁎" + static let sfSymbolCircledDotSign = "􀍷" static let sfSymbolCircledNumber0 = "􀀸" static let sfSymbolCircledNumber10 = "􀓵"