feat: better recognizes tabbed windows

This commit is contained in:
lwouis
2026-03-26 22:50:19 +01:00
parent ff1e92303a
commit 170bd673e0
8 changed files with 253 additions and 93 deletions
+4
View File
@@ -258,6 +258,7 @@
D04BA6CC689662DECB2BE55E /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = D04BAC0253EAB4D509C4813F /* InfoPlist.strings */; };
D04BA72B336C7175871B281C /* general@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = D04BAF6E2AB002763931645A /* general@2x.png */; };
D04BA737008AA2CD4E230A21 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA10777505D8A67ABD186 /* Application.swift */; };
BB0000000000000000000002 /* TabGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000001 /* TabGroup.swift */; };
D04BA73E90EFEF8247A5105D /* CGWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BAC34CFD42A7F6F1F01C0 /* CGWindow.swift */; };
D04BA76A74267B1346D23687 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA6D57A1456C07318B8EA /* GridView.swift */; };
D04BA7B8D599E1A7A27FF5AE /* AcknowledgmentsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04BA788228BA86D9EFBD1ED /* AcknowledgmentsTab.swift */; };
@@ -606,6 +607,7 @@
D04BA0E1C5DBC07108AC2F54 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
D04BA0F5EB832B8E142B654B /* 4 windows - 1 line.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "4 windows - 1 line.jpg"; sourceTree = "<group>"; };
D04BA10777505D8A67ABD186 /* Application.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
BB0000000000000000000001 /* TabGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroup.swift; sourceTree = "<group>"; };
D04BA107C8B8FE7FF8536606 /* too many windows - 4 lines - paginated.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "too many windows - 4 lines - paginated.jpg"; sourceTree = "<group>"; };
D04BA1232AFEEFE90D5CC827 /* debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = debug.xcconfig; sourceTree = "<group>"; };
D04BA18724DC58FA95DB33DB /* Podfile.lock */ = {isa = PBXFileReference; lastKnownFileType = file.lock; path = Podfile.lock; sourceTree = "<group>"; };
@@ -1418,6 +1420,7 @@
D04BA015A45DE7AFDC9794FE /* Window.swift */,
D04BA10777505D8A67ABD186 /* Application.swift */,
D04BA282BB16C1554595A968 /* Applications.swift */,
BB0000000000000000000001 /* TabGroup.swift */,
D04BAB74451B79FE18B8BEDF /* BackgroundWork.swift */,
D04BACABD048E62EBE4576CC /* DebugProfile.swift */,
D04BAC8857A527C2E15D6598 /* events */,
@@ -2561,6 +2564,7 @@
5F21C9E82C6E96A00091F72F /* SheetWindow.swift in Sources */,
D04BA737008AA2CD4E230A21 /* Application.swift in Sources */,
D04BA2A6FF9DDDC5A1A68E36 /* Applications.swift in Sources */,
BB0000000000000000000002 /* TabGroup.swift in Sources */,
AA974BA929B7D84C0099A29E /* PreviewPanel.swift in Sources */,
D04BA3CF766857381519B892 /* BackgroundWork.swift in Sources */,
D04BA48B00B4211A465C7337 /* DebugProfile.swift in Sources */,
+18
View File
@@ -241,6 +241,24 @@ extension AXUIElement {
func performAction(_ action: String) throws {
try throwIfNotSuccess(AXUIElementPerformAction(self, action as CFString))
}
/// Query the window's AXTabGroup child to detect OS-level tabs.
/// Returns tab titles if the window has tabs (always 2), nil otherwise.
/// `children` should come from the prior `.attributes([..., kAXChildrenAttribute])` call.
static func tabGroupInfo(_ children: [AXUIElement]?) -> [String]? {
guard let children else { return nil }
for child in children {
let a = try? child.attributes([kAXRoleAttribute, kAXChildrenAttribute])
guard a?.role == "AXTabGroup", let tabChildren = a?.children else { continue }
let titles = tabChildren.compactMap { tab -> String? in
let t = try? tab.attributes([kAXSubroleAttribute, kAXTitleAttribute])
guard t?.subrole == "AXTabButton" else { return nil }
return t?.title ?? ""
}
return titles.count >= 2 ? titles : nil
}
return nil
}
}
/// tests have shown that this ID has a range going from 0 to probably UInt.MAX
File diff suppressed because one or more lines are too long
+11 -3
View File
@@ -161,13 +161,21 @@ class Application: NSObject {
func manuallyUpdateWindow(_ axWindow: AXUIElement, _ wid: CGWindowID) throws {
guard wid != 0 && wid != TilesPanel.shared.windowNumber else { return } // some bogus "windows" have wid 0
let level = wid.level()
let a = try axWindow.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
// if we query .children on ourselves, AppKit calls layout directly from our thread instead of IPC; we avoid this
let isSelf = pid == ProcessInfo.processInfo.processIdentifier
let keys = [kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute] + (isSelf ? [] : [kAXChildrenAttribute])
let a = try axWindow.attributes(keys)
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
let findOrCreate = Windows.findOrCreate(axWindow, wid, self, level, a.title, a.subrole, a.role, a.size, a.position, a.isFullscreen, a.isMinimized)
guard let window = findOrCreate.0 else { return }
if findOrCreate.1 {
Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" }
var tabStateChanged = false
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
}
if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) {
if findOrCreate.1 { Logger.info { "manuallyUpdateWindows found a new window:\(window.debugId)" } }
App.refreshOpenUiAfterExternalEvent([window])
}
}
+95
View File
@@ -0,0 +1,95 @@
import Cocoa
class TabGroup {
/// Parse AXTabGroup children from a prior `.attributes([..., kAXChildrenAttribute])` call.
/// Returns tab titles if the window has tabs (always >= 2), nil otherwise.
static func extractTabTitles(_ children: [AXUIElement]?) -> [String]? {
AXUIElement.tabGroupInfo(children)
}
/// Find the active tab (non-isTabbed) sibling in the same tab group.
static func activeTabSibling(of window: Window) -> Window? {
guard let siblingWids = window.tabbedSiblingWids else { return nil }
return Windows.list.first { sibling in
sibling !== window && !sibling.isTabbed
&& sibling.cgWindowId != nil && siblingWids.contains(sibling.cgWindowId!)
}
}
/// When a window is removed from the list, update its former siblings' tab group.
/// If only 1 sibling remains, clear its tab state (a single window can't be tabbed).
static func removedWindowFromGroup(wid: CGWindowID?, siblingWids: [CGWindowID]) {
let remainingWids = siblingWids.filter { $0 != wid }
let remainingSiblings = Windows.list.filter { w in
w.cgWindowId != nil && remainingWids.contains(w.cgWindowId!)
}
if remainingSiblings.count <= 1 {
// no longer a tab group
for s in remainingSiblings {
s.tabbedSiblingWids = nil
s.isTabbed = false
}
} else {
// shrink the group
for s in remainingSiblings {
s.tabbedSiblingWids = remainingWids
}
}
}
/// Update tab state for a window and its siblings using AX-discovered tab titles.
/// Resolves titles to WIDs, propagates space info from active to inactive tabs,
/// and clears stale state on windows no longer in the group.
/// Returns true if any window's tab state or space changed.
@discardableResult
static func updateState(_ activeTab: Window, _ siblingTitles: [String]?) -> Bool {
var changed = false
guard let titles = siblingTitles else {
// only clear state if this window was the active tab of its group;
// inactive tabs report nil titles (no AXTabGroup child) but are still tabbed
if activeTab.tabbedSiblingWids != nil && !activeTab.isTabbed {
activeTab.tabbedSiblingWids = nil
changed = true
}
return changed
}
let pid = activeTab.application.pid
var siblingWids = [CGWindowID]()
if let wid = activeTab.cgWindowId { siblingWids.append(wid) }
var matchedSiblings = [Window]()
// remove one occurrence of the active tab's title (not all there may be duplicate titles)
var remainingTitles = titles
if let i = remainingTitles.firstIndex(of: activeTab.title) {
remainingTitles.remove(at: i)
}
for title in remainingTitles {
if let sibling = (Windows.list.first { s in
s !== activeTab && s.application.pid == pid && s.title == title
&& !matchedSiblings.contains(where: { $0 === s })
}) {
matchedSiblings.append(sibling)
if let wid = sibling.cgWindowId { siblingWids.append(wid) }
}
}
if activeTab.tabbedSiblingWids != siblingWids { changed = true }
activeTab.tabbedSiblingWids = siblingWids
activeTab.isTabbed = false
for sibling in matchedSiblings {
if !sibling.isTabbed || sibling.tabbedSiblingWids != siblingWids || sibling.spaceIds != activeTab.spaceIds { changed = true }
sibling.tabbedSiblingWids = siblingWids
sibling.isTabbed = true
sibling.spaceIds = activeTab.spaceIds
sibling.spaceIndexes = activeTab.spaceIndexes
sibling.isOnAllSpaces = activeTab.isOnAllSpaces
}
for window in Windows.list where window !== activeTab && window.application.pid == pid
&& !matchedSiblings.contains(where: { $0 === window }) {
if window.tabbedSiblingWids != nil {
window.tabbedSiblingWids = nil
window.isTabbed = false
changed = true
}
}
return changed
}
}
+8 -4
View File
@@ -20,6 +20,7 @@ class Window {
var icon: CGImage? { get { application.icon } }
var shouldShowTheUser = true
var isTabbed: Bool = false
var tabbedSiblingWids: [CGWindowID]?
var isHidden: Bool { get { application.isHidden } }
var dockLabel: String? { get { application.dockLabel } }
var isFullscreen = false
@@ -269,7 +270,11 @@ class Window {
private func updateSpaces() {
guard let cgWindowId else { return }
let spaceIds = cgWindowId.spaces()
var spaceIds = cgWindowId.spaces()
// inactive tabs return no space from CGSCopySpacesForWindows; use the active tab sibling's space
if spaceIds.isEmpty, let activeTab = TabGroup.activeTabSibling(of: self) {
spaceIds = activeTab.spaceIds
}
self.spaceIds = spaceIds
self.spaceIndexes = spaceIds.compactMap { spaceId in Spaces.idsAndIndexes.first { $0.0 == spaceId }?.1 }
self.isOnAllSpaces = spaceIds.count > 1
@@ -300,9 +305,8 @@ class Window {
func referenceWindowForTabbedWindow() -> Window? {
// if the window is tabbed, we can't know its position/size before it's focused, so we use the currently
// visible window-tab. Its data will match the tabbed window's
// TODO: handle the case where the app has multiple window-groups. In that case, we need to find the right
// window-group, instead of picking the focused one
return isTabbed ? application.focusedWindow : self
// fallback to the focusedWindow
isTabbed ? (TabGroup.activeTabSibling(of: self) ?? application.focusedWindow) : self
}
// Determines if this window is the main application window
+4 -35
View File
@@ -87,27 +87,6 @@ class Windows {
}
}
/// tabs detection is a flaky work-around the lack of public API to observe OS tabs
/// see: https://github.com/lwouis/alt-tab-macos/issues/1540
private static func detectTabbedWindows(_ window: Window, _ cgsWindowIds: [CGWindowID], _ visibleCgsWindowIds: [CGWindowID], _ pidsWithVisibleWindow: Set<pid_t>) {
if let cgWindowId = window.cgWindowId {
if window.isMinimized || window.isHidden {
if #available(macOS 13.0, *) {
// not exact after window merging
window.isTabbed = !cgsWindowIds.contains(cgWindowId)
} else {
// not known
window.isTabbed = false
}
} else {
// a real inactive tab always has a visible sibling (the active tab) from the same app
// apps like Teams/WeChat hide windows without destroying them; those have no visible sibling
let notInVisibleList = !visibleCgsWindowIds.contains(cgWindowId)
window.isTabbed = notInVisibleList && pidsWithVisibleWindow.contains(window.application.pid)
}
}
}
static func updatesBeforeShowing() -> Bool {
if MissionControl.state() == .showAllWindows || MissionControl.state() == .showFrontWindows { return false }
if list.isEmpty { return true }
@@ -115,21 +94,7 @@ class Windows {
// workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked,
// switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually
Spaces.refresh()
let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 }
lazy var cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes)
lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false)
lazy var pidsWithVisibleWindow: Set<pid_t> = {
let visibleSet = Set(visibleCgsWindowIds)
var pids = Set<pid_t>()
for window in list {
if let wid = window.cgWindowId, visibleSet.contains(wid) {
pids.insert(window.application.pid)
}
}
return pids
}()
for window in list {
detectTabbedWindows(window, cgsWindowIds, visibleCgsWindowIds, pidsWithVisibleWindow)
window.updateSpacesAndScreen()
refreshIfWindowShouldBeShownToTheUser(window)
}
@@ -552,6 +517,10 @@ class Windows {
Applications.manualWindowUpdatesThrottler.removeEntry(withKey: "\(wid)")
AccessibilityEvents.removeThrottlerEntries(wid: wid)
}
// when a tabbed window is removed, update its former siblings' tab group
if let siblingWids = w.tabbedSiblingWids {
TabGroup.removedWindowFromGroup(wid: w.cgWindowId, siblingWids: siblingWids)
}
}
if addWindowlessWindowIfNeeded {
windows.forEach { $0.application.addWindowlessWindowIfNeeded() }
+11 -4
View File
@@ -80,8 +80,7 @@ class AccessibilityEvents {
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
return $0.application.pid == pid
}
// if we process the "shown" event too fast, the window won't be listed by CGSCopyWindowsWithOptionsAndTags
// it will thus be detected as isTabbed. We add a delay to work around this scenario
// if we process the "shown" event too fast, UI may not be ready; we add a delay to work around this
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) {
App.refreshOpenUiAfterExternalEvent(windows)
}
@@ -98,7 +97,11 @@ class AccessibilityEvents {
return
}
let level = wid.level()
let a = try element.attributes([kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute])
// if we query .children on ourselves, AppKit calls layout directly from our thread instead of IPC; we avoid this
let isSelf = pid == ProcessInfo.processInfo.processIdentifier
let keys = [kAXTitleAttribute, kAXSubroleAttribute, kAXRoleAttribute, kAXSizeAttribute, kAXPositionAttribute, kAXFullscreenAttribute, kAXMinimizedAttribute] + (isSelf ? [] : [kAXChildrenAttribute])
let a = try element.attributes(keys)
let tabSiblingTitles = isSelf ? nil : TabGroup.extractTabTitles(a.children)
DispatchQueue.main.async {
guard let app = Applications.findOrCreate(pid, false) else { return }
Logger.info { "\(type) wid:\(wid) app:\(app.debugId)" }
@@ -111,7 +114,11 @@ class AccessibilityEvents {
return
}
Logger.debug { "\(type) win:\(window.debugId)" }
if findOrCreate.1 {
var tabStateChanged = false
if tabSiblingTitles != nil || window.tabbedSiblingWids != nil {
tabStateChanged = TabGroup.updateState(window, tabSiblingTitles)
}
if findOrCreate.1 || (tabStateChanged && App.appIsBeingUsed) {
App.refreshOpenUiAfterExternalEvent([window])
}
if type == kAXMainWindowChangedNotification || type == kAXFocusedWindowChangedNotification {