feat: better recognizes tabbed windows
This commit is contained in:
1 parent
ff1e92303a
commit
170bd673e0
8 files changed
+253
-93
No files matched your search
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,47 +1,102 @@
|
||||
import Foundation
|
||||
import ScreenCaptureKit
|
||||
|
||||
if #available(macOS 13.1, *) {
|
||||
SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { content, error in
|
||||
let normal = content!.windows.first { $0.windowID == 9345 }!
|
||||
let tabbed = content!.windows.first { $0.windowID == 9867 }!
|
||||
let hidden = content!.windows.first { $0.windowID == 10690 }!
|
||||
Logger.error("normal", normal.title, normal.isActive, normal.isOnScreen, normal.windowLayer, normal.frame)
|
||||
Logger.error("tabbed", tabbed.title, tabbed.isActive, tabbed.isOnScreen, tabbed.windowLayer, tabbed.frame)
|
||||
Logger.error("hidden", hidden.title, hidden.isActive, hidden.isOnScreen, hidden.windowLayer, hidden.frame)
|
||||
Logger.error("SCShareableContent", content!.windows.sorted { $0.windowID < $1.windowID }.map { $0.windowID })
|
||||
}
|
||||
}
|
||||
let windows = CGWindowListCopyWindowInfo([.excludeDesktopElements, .optionOnScreenOnly], kCGNullWindowID) as! [CGWindow]
|
||||
Logger.error("CGWindowListCopyWindowInfo extra", windows.sorted { $0.id()! < $1.id()! }.map { ($0.id()!, $0.title()) })
|
||||
Logger.error("CGWindowListCopyWindowInfo", windows.sorted { $0.id()! < $1.id()! }.map { $0.id()! })
|
||||
Spaces.refresh()
|
||||
let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 }
|
||||
let cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes)
|
||||
let visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false)
|
||||
Logger.error("CGSCopyWindowsWithOptionsAndTags include invisible", cgsWindowIds.sorted { $0 < $1 })
|
||||
Logger.error("CGSCopyWindowsWithOptionsAndTags exclude invisible", visibleCgsWindowIds.sorted { $0 < $1 })
|
||||
|
||||
|
||||
// CGWindowListCopyWindowInfo extra [(28, "Clock"), (29, "Sound"), (30, "BentoBox-0"), (31, "Item-0"), (42, "Item-0"), (49, "Item-1"), (57, "Item-0"), (59, "raycastIcon"), (79, "Item-0"), (87, "Item-0"), (125, "com.bjango.istatmenus.memory"), (126, "com.bjango.istatmenus.battery"), (127, "com.bjango.istatmenus.cpu"), (128, "com.bjango.istatmenus.disks"), (129, "com.bjango.istatmenus.network"), (130, "com.bjango.istatmenus.sensors"), (131, "com.bjango.istatmenus.time"), , (9297, "BetterTouchTool"), (9325, ""), (9345, "alt-tab-macos [~/git/alt-tab-macos] – /Users/lwouis/git/alt-tab-macos/src/ui/App.swift"), (11123, "Item-0"), (11160, "Menubar"), (11281, "StatusIndicator")]
|
||||
// SCShareableContent extra [(2, Optional("Display 1 Backstop")), (3, Optional("Display 2 Backstop")), (5, Optional("Cursor")), (16, Optional("Dock")), (19, Optional("")), (20, Optional("Notification Center")), (21, Optional("Accessibility")), (23, Optional("")), (24, Optional("")), (25, Optional("")), (26, Optional("")), (27, Optional("")), (28, Optional("Clock")), (29, Optional("Sound")), (30, Optional("BentoBox-0")), (31, Optional("Item-0")), (37, Optional("")), (42, Optional("Item-0")), (44, Optional("Spotlight")), (45, Optional("Spotlight")), (49, Optional("Item-1")), (56, Optional("")), (57, Optional("Item-0")), (58, Optional("")), (59, Optional("raycastIcon")), (61, Optional("")), (64, Optional("")), (72, Optional("")), (76, Optional("Logger.error(\"CGWindow…itle() ?? \"nil\") })... (2 lines)")), (79, Optional("Item-0")), (85, Optional("Handy")), (86, Optional("Recording")), (87, Optional("Item-0")), (95, Optional("")), (96, Optional("")), (97, Optional("")), (98, Optional("")), (99, Optional("")), (100, Optional("")), (101, Optional("")), (102, Optional("")), (103, Optional("")), (104, Optional("")), (105, Optional("")), (106, Optional("")), (107, Optional("")), (108, Optional("")), (109, Optional("")), (110, Optional("")), (111, Optional("")), (112, Optional("")), (113, Optional("")), (114, Optional("")), (115, Optional("")), (116, Optional("")), (117, Optional("")), (118, Optional("")), (119, Optional("")), (121, Optional("")), (122, Optional("")), (123, Optional("")), (124, Optional("")), (125, Optional("com.bjango.istatmenus.memory")), (126, Optional("com.bjango.istatmenus.battery")), (127, Optional("com.bjango.istatmenus.cpu")), (128, Optional("com.bjango.istatmenus.disks")), (129, Optional("com.bjango.istatmenus.network")), (130, Optional("com.bjango.istatmenus.sensors")), (131, Optional("com.bjango.istatmenus.time")), (132, Optional("")), (140, Optional("")), (202, Optional("(25811) Subscriptions - YouTube")), (203, Optional("")), (204, Optional("")), ), (206, Optional("")), (207, Optional("")), (215, Optional("")), (289, Optional("")), (334, Optional("")), (875, Optional("")), (1713, Optional("Display 2 Backstop")), (2148, Optional("")), (2150, Optional("")), (3302, Optional("")), (4384, Optional("")), (6249, Optional("Display 2 Backstop")), (9296, Optional("Colors")), (9297, Optional("BetterTouchTool")), (9309, Optional("")), (9310, Optional("")), (9311, Optional("")), (9312, Optional("")), (9313, Optional("Display 2 Backstop")), (9314, Optional("")), (9315, Optional("")), (9316, Optional("")), (9317, Optional("")), (9318, Optional("")), (9319, Optional("")), (9320, Optional("")), (9321, Optional("")), (9322, Optional("")), (9324, Optional("")), (9325, Optional("")), (9328, Optional("Wallpaper-6D2AE1E5-4293-4F35-A980-0E81D9E360BF")), (9329, Optional("")), (9331, Optional("")), (9339, Optional("")), (9340, Optional("")), (9341, Optional("")), (9342, Optional("")), (9343, Optional("")), , (9346, Optional("")), (9450, Optional("")), (9451, Optional("")), (9452, Optional("")), (9453, Optional("")), (9454, Optional("")), , (9456, Optional("")), , (9875, Optional("")), (9972, Optional("Wallpaper-9FA063D2-E6B4-4569-82CC-3C345CB5BEAD")), (9978, Optional("Offscreen Wallpaper Window")), (9995, Optional("Menubar")), (9996, Optional("underbelly")), , (10021, Optional("")), (10022, Optional("")), (10025, Optional("")), (10026, Optional("Wallpaper-1448183E-49F8-4B98-90FA-4D983B169ECD")), (10027, Optional("Fullscreen Backdrop")), (10028, Optional("")), (10032, Optional("")), (10033, Optional("")), (10034, Optional("")), (10035, Optional("com.lwouis.menubar-webview")), (10036, Optional("com.lwouis.webcam-zoom")), (10037, Optional("com.naotanhaocan.BetterMouse")), (10038, Optional("pro.betterdisplay.BetterDisplay")), (10039, Optional("com.raycast.macos")), (10040, Optional("io.github.hluk.CopyQ")), (10041, Optional("com.pais.handy")), (10042, Optional("com.bjango.istatmenus.status")), (10043, Optional("com.bjango.istatmenus.status")), (10044, Optional("com.bjango.istatmenus.status")), (10045, Optional("com.bjango.istatmenus.status")), (10046, Optional("com.bjango.istatmenus.status")), (10047, Optional("com.bjango.istatmenus.status")), (10048, Optional("com.bjango.istatmenus.status")), (10049, Optional("com.hegenberg.BetterTouchTool")), (10246, Optional("Menubar")), (10247, Optional("underbelly")), (10417, nil), (10419, Optional("Gesture Blocking Overlay")), (10443, Optional("Gesture Blocking Overlay")), (10469, nil), (10572, Optional("Keyboard Input")), , (10694, Optional("")), (10952, Optional("")), (11036, Optional("")), (11119, Optional("")), (11120, Optional("AltTab needs some permissions")), (11121, Optional("")), (11122, Optional("")), (11123, Optional("Item-0")), (11124, Optional("com.lwouis.alt-tab-macos")), (11125, Optional("General")), (11126, Optional("")), (11127, Optional("")), (11128, Optional("")), (11129, Optional("")), (11130, Optional("Send feedback")), (11160, Optional("Menubar")), (11Line truncated
|
||||
|
||||
// 1. CGWindowListCopyWindowInfo [28, 29, 30, 31, 42, 49, 57, 59, 79, 87, 125, 126, 127, 128, 129, 130, 131, 202, 9297, 9325, 9345, 11123, 11160, 11281]
|
||||
// 2. CGSCopyWindowsWithOptionsAndTags include invisible [21, 28, 29, 30, 31, 42, 45, 49, 57, 59, 76, 79, 85, 86, 87, 125, 126, 127, 128, 129, 130, 131, 202, 203, 204, 205, 289, 334, 3302, 9297, 9325, 9328, 9345, 9455, 9972, 9978, 9995, 9996, 10020, 10021, 10022, 10025, 10026, 10027, 10028, 10246, 10247, 10419, 10443, 10690, 10694, 11121, 11123, 11160, 11161, 11246]
|
||||
// 3. CGSCopyWindowsWithOptionsAndTags exclude invisible [28, 29, 30, 31, 42, 49, 57, 59, 79, 87, 125, 126, 127, 128, 129, 130, 131, 202, 9297, 9325, 9328, 9345, 9455, 9972, 9978, 9995, 10020, 10021, 10022, 10025, 10026, 10027, 11123, 11160]
|
||||
// 4. SCShareableContent.getWithCompletionHandler [2, 3, 5, 16, 19, 20, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 37, 42, 44, 45, 49, 56, 57, 58, 59, 61, 64, 72, 76, 79, 85, 86, 87, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 140, 202, 203, 204, 205, 206, 207, 215, 289, 334, 875, 1713, 2148, 2150, 3302, 4384, 6249, 9296, 9297, 9309, 9310, 9311, 9312, 9313, 9314, 9315, 9316, 9317, 9318, 9319, 9320, 9321, 9322, 9324, 9325, 9328, 9329, 9331, 9339, 9340, 9341, 9342, 9343, 9345, 9346, 9450, 9451, 9452, 9453, 9454, 9455, 9456, 9867, 9875, 9972, 9978, 9995, 9996, 10020, 10021, 10022, 10025, 10026, 10027, 10028, 10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, 10045, 10046, 10047, 10048, 10049, 10246, 10247, 10417, 10419, 10443, 10469, 10572, 10690, 10694, 10952, 11036, 11119, 11120, 11121, 11122, 11123, 11124, 11125, 11126, 11127, 11128, 11129, 11130, 11160, 11161, 11162, 11164, 11204, 11246, 11247, 11280, 11281]
|
||||
// 5. SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) [5, 16, 20, 21, 23, 24, 25, 26, 27, 28, 29, 30, 31, 37, 42, 44, 45, 49, 56, 57, 58, 59, 61, 64, 72, 76, 79, 85, 86, 87, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 140, 202, 203, 204, 205, 206, 207, 215, 289, 334, 875, 2148, 2150, 3302, 4384, 9296, 9297, 9309, 9310, 9311, 9312, 9314, 9315, 9316, 9317, 9318, 9319, 9320, 9321, 9322, 9324, 9325, 9329, 9331, 9339, 9340, 9341, 9342, 9343, 9345, 9346, 9450, 9451, 9452, 9453, 9454, 9455, 9456, 9867, 9875, 9995, 10020, 10021, 10022, 10025, 10028, 10032, 10033, 10034, 10035, 10036, 10037, 10038, 10039, 10040, 10041, 10042, 10043, 10044, 10045, 10046, 10047, 10048, 10049, 10246, 10419, 10443, 10469, 10690, 10694, 10952, 11119, 11120, 11121, 11122, 11123, 11124, 11125, 11126, 11127, 11128, 11129, 11130, 11160, 11162, 11164, 11204, 11246, 11247, 11334, 11336, 11348]
|
||||
|
||||
// (202, Optional("(25811) Subscriptions - YouTube") | normal | 1,2,3,4
|
||||
// (9345, Optional("alt-tab-macos [~/git/alt-tab-macos] – /Users/lwouis/git/alt-tab-macos/src/ui/App.swift")) | normal | 1,2,3,4
|
||||
// (10690, Optional("Movies")) | hidden | 2,4
|
||||
// (9867, Optional("lwouis")) | hidden + tabbed | 4
|
||||
// (9455, Optional("~/git/alt-tab-macos/DerivedData/Build/Products/Release")) | another space | 2,3,4
|
||||
// (10020, Optional("(25811) Ryuichi Sakamoto: Playing the Piano for the Isolated - YouTube 🔊")) | fullscreen | 2,3,4
|
||||
// (205, Optional("(9+) K8s Migration Eng Test Day - Objectives & Guidelines") | minimized | 2,4
|
||||
|
||||
// Conclusions:
|
||||
// * CGWindowListCopyWindowInfo returns the least windows
|
||||
// * SCShareableContent returns the most windows
|
||||
// * In order to detect tabs, we need some difference with non-tabbed windows. We need contrast.
|
||||
// Today we make 2 calls to CGSCopyWindowsWithOptionsAndTags, with different options, and compare results.
|
||||
// Tabbed Window Detection — Investigation Results
|
||||
// =================================================
|
||||
//
|
||||
// macOS provides no public API to detect whether a window is an inactive OS tab.
|
||||
// This file documents all approaches tested and their results.
|
||||
//
|
||||
// Test setup: Finder with 2 tabbed windows ("lwouis" inactive tab, "git" active tab)
|
||||
// and 1 non-tabbed window ("Movies").
|
||||
//
|
||||
// MARK: - Approaches that DON'T work
|
||||
//
|
||||
// 1. CGWindowListCopyWindowInfo
|
||||
// Returns the fewest windows. Inactive tabs don't appear at all.
|
||||
// Can't distinguish "absent because tabbed" from "absent for other reasons."
|
||||
//
|
||||
// 2. CGSCopyWindowsWithOptionsAndTags (current heuristic)
|
||||
// Comparing include-invisible vs exclude-invisible lists: inactive tabs appear
|
||||
// in the include-invisible list but not the exclude-invisible list. However, so
|
||||
// do windows from apps like Teams/WeChat that hide windows without destroying
|
||||
// them. This is the source of false positives in the current detection.
|
||||
//
|
||||
// 3. SCShareableContent (macOS 13+)
|
||||
// Returns the most windows (including inactive tabs). However isActive=false and
|
||||
// isOnScreen=false for inactive tabs — same as for hidden, minimized, and
|
||||
// other-space windows. No distinguishing signal.
|
||||
//
|
||||
// 4. SLSWindowIterator (parentId, tags, attributes, attachedWindows, spaceAttributes)
|
||||
// - parentId: returns 0 for all tested windows (tabbed and non-tabbed)
|
||||
// - attachedCount/attachedWindows: always 0/empty for tabbed windows
|
||||
// - spaceAttributes/spaceTypeMask: no difference between tabbed and non-tabbed
|
||||
// - tags (UInt from iterator): different patterns exist between visible and invisible
|
||||
// windows, but the same pattern applies to both inactive tabs AND other invisible
|
||||
// windows (hidden apps, other-space windows). No tab-specific bit.
|
||||
//
|
||||
// 5. SLSGetWindowTags (64-bit tags)
|
||||
// Compared binary tag patterns across window states:
|
||||
// Active tab (git): 1100000000000000000000000100000000010010000010000000000001
|
||||
// Non-tabbed (Movies): 1100000000000000000000000100000000010010000010000000000001
|
||||
// Inactive tab (lwouis): 1100000000000000000000000100000000010010000000000000000001
|
||||
// The inactive tab differs in a few bits, but the same pattern appears for hidden
|
||||
// and other-space windows. Not a reliable tab-specific signal.
|
||||
//
|
||||
// 6. SLSGetWindowSubLevel
|
||||
// Returns 0 for all normal layer-0 windows regardless of tab state.
|
||||
//
|
||||
// 7. CGSCopyWindowGroup (movementGroup, orderingGroup, tabGroup, tabbingGroup)
|
||||
// - movementGroup: empty for tabbed windows
|
||||
// - orderingGroup: empty for tabbed windows
|
||||
// - tabGroup: empty (speculative key, doesn't exist)
|
||||
// - tabbingGroup: empty (speculative key, doesn't exist)
|
||||
//
|
||||
// 8. CGSCopyWindowProperty (broad key sweep)
|
||||
// Tested ~40 speculative keys including kCGSWindowTabGroupID, kCGSWindowIsTab,
|
||||
// kCGSWindowTabParent, kCGSWindowTabIndex, kCGSWindowTabbingIdentifier,
|
||||
// kCGSWindowContainerID, TabGroupID, IsTab, etc.
|
||||
// Only kCGSWindowTitle returned values. No tab-related properties exist.
|
||||
//
|
||||
// 9. SLSCopyAssociatedWindows
|
||||
// Returns only the queried window itself. No sibling information.
|
||||
//
|
||||
// 10. Frame-based clustering (CGWindowListCopyWindowInfo frame comparison)
|
||||
// Grouping same-pid windows by identical frame: the active tab's frame is known,
|
||||
// but inactive tabs have a DIFFERENT frame (their last known position before being
|
||||
// tabbed). Only works if tabs were at the same position before merging, which is
|
||||
// not guaranteed. Unreliable.
|
||||
//
|
||||
// MARK: - Approach that WORKS: AXTabGroup (Accessibility API)
|
||||
//
|
||||
// Querying kAXChildrenAttribute on a window's AXUIElement reveals an AXTabGroup child
|
||||
// element when the window has OS-level tabs. The AXTabGroup's children are the individual
|
||||
// tab buttons. This provides a definitive, reliable signal.
|
||||
//
|
||||
// How it works:
|
||||
// 1. Get kAXChildrenAttribute on the window's AXUIElement
|
||||
// 2. Find the child with kAXRoleAttribute == "AXTabGroup"
|
||||
// 3. Get that child's kAXChildrenAttribute
|
||||
// 4. Filter children to those with kAXSubroleAttribute == "AXTabButton"
|
||||
// (this excludes the "+" button which has role=AXButton, subrole=nil)
|
||||
// 5. Each AXTabButton has:
|
||||
// - kAXTitleAttribute: the tab's title (matches the window title)
|
||||
// - kAXValueAttribute: 1 for active tab, 0 for inactive tab
|
||||
// - _AXUIElementGetWindow: returns the parent window's WID (not individual tab WIDs)
|
||||
//
|
||||
// Example output for Finder with tabs "lwouis" and "git" (git is active):
|
||||
// AXTabGroup has 3 children:
|
||||
// [0] role=AXRadioButton subrole=AXTabButton title='lwouis' value=0
|
||||
// [1] role=AXRadioButton subrole=AXTabButton title='git' value=1
|
||||
// [2] role=AXButton subrole=nil title='' value=nil ← "+" button
|
||||
//
|
||||
// Non-tabbed windows (e.g. "Movies") have no AXTabGroup child at all.
|
||||
//
|
||||
// Properties:
|
||||
// - Definitive: presence of AXTabGroup = tabbed, absence = not tabbed
|
||||
// - Rich: provides tab count and all tab titles
|
||||
// - Public API: uses standard Accessibility framework (no private APIs needed)
|
||||
// - Works cross-space: querying via an existing AXUIElement reference works even
|
||||
// when the window is on a different Space (unlike kAXWindowsAttribute on the app
|
||||
// element, which only returns windows on the current Space)
|
||||
// - Limitation: _AXUIElementGetWindow on tab buttons returns the parent window WID,
|
||||
// not individual tab WIDs. Matching tabs to windows must use title comparison.
|
||||
//
|
||||
// Implementation: see AXUIElement.tabGroupInfo() in src/api-wrappers/AXUIElement.swift
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user