feat: handle hidden app windows (closes #108)
This commit is contained in:
1 parent
fdb132779c
commit
6fcf092690
6 files changed
+63
-33
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
Reference in new issue
Block a user