diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index 265c9387..2c9d73c8 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -43,9 +43,12 @@ extension AXUIElement { return attribute(kAXTitleAttribute, String.self) } - func windows(_ bundleIdentifier: String?) -> [AXUIElement]? { - return attribute(kAXWindowsAttribute, [AXUIElement].self)? - .filter { $0.isActualWindow(bundleIdentifier) } + func windows() -> [AXUIElement]? { + return attribute(kAXWindowsAttribute, [AXUIElement].self) + } + + func isTabbed(_ window: AXUIElement) -> Bool { + return windows()?.first(where: { $0 == window }) == nil } func isMinimized() -> Bool { diff --git a/src/logic/Application.swift b/src/logic/Application.swift index f78a6fbe..755820e6 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -12,6 +12,7 @@ class Application: NSObject { kAXWindowCreatedNotification, kAXApplicationHiddenNotification, kAXApplicationShownNotification, + kAXFocusedUIElementChangedNotification, ] // some apps never finish their subscription retry loop; they should be stopped to avoid infinite loop @@ -51,7 +52,8 @@ class Application: NSObject { } func observeNewWindows() { - if let windows = axUiElement!.windows(runningApplication.bundleIdentifier) { + if let windows = (axUiElement!.windows()? + .filter { $0.isActualWindow(runningApplication.bundleIdentifier) }) { let actualWindows = windows.filter { Windows.list.firstIndexThatMatches($0) == nil } if actualWindows.count > 0 { addWindows(actualWindows) diff --git a/src/logic/Window.swift b/src/logic/Window.swift index b8ea8b63..0c5d1101 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -6,6 +6,7 @@ class Window { var thumbnail: NSImage? var icon: NSImage? var shouldShowTheUser = true + var isTabbed: Bool var isHidden: Bool var isMinimized: Bool var isOnAllSpaces: Bool @@ -36,6 +37,7 @@ class Window { self.application = application self.cgWindowId = axUiElement.cgWindowId() self.icon = application.runningApplication.icon + self.isTabbed = application.axUiElement!.isTabbed(axUiElement) self.isHidden = application.runningApplication.isHidden self.isMinimized = axUiElement.isMinimized() self.spaceId = Spaces.currentSpaceId diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index 34ca11fe..0cef7a68 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -51,6 +51,8 @@ class Windows { } static func updatesWindowSpace(_ window: Window) { + // macOS bug: if you tab a window, then move the tab group to another space, other tabs from the tab group will stay on the current space + // you can use the Dock to focus one of the other tabs and it will teleport that tab in the current space, proving that it's a macOS bug let spaceIds = window.cgWindowId.spaces() if spaceIds.count == 1 { window.spaceId = spaceIds.first! @@ -104,13 +106,7 @@ class Windows { !(Preferences.appsToShow == .active && window.application.runningApplication != NSWorkspace.shared.frontmostApplication) && !(Preferences.spacesToShow == .active && window.spaceId != Spaces.currentSpaceId) && !(Preferences.screensToShow == .showingAltTab && !isOnScreen(window, screen)) && - (Preferences.showTabsAsWindows || !isTabbed(window)) - } - - static func isTabbed(_ window: Window) -> Bool { - let app = window.application - let windows = app.axUiElement?.windows(app.runningApplication.bundleIdentifier) - return windows?.first(where: { $0 == window.axUiElement }) == nil + (Preferences.showTabsAsWindows || !window.isTabbed) } static func isOnScreen(_ window: Window, _ screen: NSScreen) -> Bool { diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 28762e4d..650492ad 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -14,10 +14,22 @@ func axObserverCallback(observer: AXObserver, element: AXUIElement, notification kAXWindowDeminiaturizedNotification: windowMiniaturizedOrDeminiaturized(element, type) case kAXTitleChangedNotification: windowTitleChanged(element) case kAXWindowResizedNotification: windowResized(element) + case kAXFocusedUIElementChangedNotification: focusedUiElementChanged(element, applicationPointer) default: return } } +private func focusedUiElementChanged(_ element: AXUIElement, _ applicationPointer: UnsafeMutableRawPointer?) { + // this event is the only opportunity we have to check if a window became a tab, or a tab became a window + // we can only detect tabs for windows on the current space, as AXUIElement.windows() only reports current space windows + let application = Unmanaged.fromOpaque(applicationPointer!).takeUnretainedValue() + Windows.list.forEach { + if $0.spaceId == Spaces.currentSpaceId && $0.application == application { + $0.isTabbed = application.axUiElement!.isTabbed($0.axUiElement) + } + } +} + private func applicationActivated(_ element: AXUIElement) { guard let appFocusedWindow = element.focusedWindow(), let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow) else { return }