mirror of
https://github.com/lwouis/alt-tab-macos.git
synced 2026-05-24 11:20:36 +00:00
feat: better recognizes tabbed windows
This commit is contained in:
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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