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)
|
return attribute(.windows, [AXUIElement].self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func window(_ id: CGWindowID) -> AXUIElement? {
|
||||||
|
return windows()?.first(where: { return id == $0.cgId() })
|
||||||
|
}
|
||||||
|
|
||||||
func isMinimized() -> Bool {
|
func isMinimized() -> Bool {
|
||||||
return attribute(.minimized, Bool.self) == true
|
return attribute(.minimized, Bool.self) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
func focus(_ id: CGWindowID) {
|
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)
|
var elementConnection = UInt32(0)
|
||||||
CGSGetWindowOwner(cgsMainConnectionId, id, &elementConnection)
|
CGSGetWindowOwner(cgsMainConnectionId, id, &elementConnection)
|
||||||
var psn = ProcessSerialNumber()
|
var psn = ProcessSerialNumber()
|
||||||
CGSGetConnectionPSN(elementConnection, &psn)
|
CGSGetConnectionPSN(elementConnection, &psn)
|
||||||
|
AXUIElementPerformAction(self, kAXRaiseAction as CFString)
|
||||||
|
makeKeyWindow(psn, id)
|
||||||
_SLPSSetFrontProcessWithOptions(&psn, id, .userGenerated)
|
_SLPSSetFrontProcessWithOptions(&psn, id, .userGenerated)
|
||||||
makeKeyWindow(psn, id)
|
makeKeyWindow(psn, id)
|
||||||
AXUIElementPerformAction(self, kAXRaiseAction as CFString)
|
AXUIElementPerformAction(self, kAXRaiseAction as CFString)
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import Cocoa
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
extension CGWindowID {
|
extension CGWindowID {
|
||||||
func AXUIElement(_ ownerPid: pid_t) -> AXUIElement? {
|
func AXUIElementApplication(_ ownerPid: pid_t) -> AXUIElement {
|
||||||
return AXUIElementCreateApplication(ownerPid).windows()?.first(where: { return $0.cgId() == self })
|
return AXUIElementCreateApplication(ownerPid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AXUIElementOfOtherSpaceWindow(_ ownerPid: pid_t) -> AXUIElement? {
|
func AXUIElementOfOtherSpaceWindow(_ axApp: AXUIElement) -> AXUIElement? {
|
||||||
CGSAddWindowsToSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId])
|
CGSAddWindowsToSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId])
|
||||||
let axWindow = AXUIElement(ownerPid)
|
let axWindow = axApp.window(self)
|
||||||
CGSRemoveWindowsFromSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId])
|
CGSRemoveWindowsFromSpaces(cgsMainConnectionId, [self], [Spaces.currentSpaceId])
|
||||||
return axWindow
|
return axWindow
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,42 +3,44 @@ import Foundation
|
|||||||
|
|
||||||
class TrackedWindow {
|
class TrackedWindow {
|
||||||
var cgWindow: CGWindow
|
var cgWindow: CGWindow
|
||||||
var ownerPid: pid_t
|
|
||||||
var id: CGWindowID
|
var id: CGWindowID
|
||||||
var title: String
|
var title: String
|
||||||
var thumbnail: NSImage?
|
var thumbnail: NSImage?
|
||||||
var icon: NSImage?
|
var icon: NSImage?
|
||||||
var app: NSRunningApplication?
|
var app: NSRunningApplication
|
||||||
|
var axApp: AXUIElement
|
||||||
var axWindow: AXUIElement?
|
var axWindow: AXUIElement?
|
||||||
|
var isHidden: Bool
|
||||||
var isMinimized: Bool
|
var isMinimized: Bool
|
||||||
var spaceId: CGSSpaceID?
|
var spaceId: CGSSpaceID?
|
||||||
var spaceIndex: SpaceIndex?
|
var spaceIndex: SpaceIndex?
|
||||||
var rank: WindowRank?
|
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.cgWindow = cgWindow
|
||||||
self.id = cgId
|
self.id = cgId
|
||||||
self.ownerPid = ownerPid
|
|
||||||
let cgTitle = cgWindow.value(.name, String.self)
|
let cgTitle = cgWindow.value(.name, String.self)
|
||||||
let cgOwnerName = cgWindow.value(.ownerName, 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
|
// 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.title = cgTitle != nil && cgTitle != "" && cgTitle != "" ? cgTitle! : cgOwnerName ?? ""
|
||||||
self.app = NSRunningApplication(processIdentifier: ownerPid)
|
self.app = app
|
||||||
self.icon = self.app?.icon
|
self.axApp = axApp
|
||||||
|
self.icon = self.app.icon
|
||||||
if let cgImage = cgId.screenshot() {
|
if let cgImage = cgId.screenshot() {
|
||||||
self.thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
self.thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
||||||
}
|
}
|
||||||
self.axWindow = axWindow
|
self.axWindow = axWindow
|
||||||
|
self.isHidden = isHidden
|
||||||
self.isMinimized = isMinimized
|
self.isMinimized = isMinimized
|
||||||
self.spaceId = spaceId
|
self.spaceId = spaceId
|
||||||
// System Preferences windows appear on all spaces, so we make them the current space
|
self.spaceIndex = spaceIndex
|
||||||
self.spaceIndex = app?.bundleIdentifier == "com.apple.systempreferences" ? Spaces.currentSpaceIndex : spaceIndex
|
|
||||||
self.rank = rank
|
self.rank = rank
|
||||||
}
|
}
|
||||||
|
|
||||||
func focus() {
|
func focus() {
|
||||||
if axWindow == nil {
|
let onCurrentSpace = axWindow != nil
|
||||||
axWindow = id.AXUIElementOfOtherSpaceWindow(ownerPid)
|
if !onCurrentSpace {
|
||||||
|
axWindow = id.AXUIElementOfOtherSpaceWindow(axApp)
|
||||||
}
|
}
|
||||||
axWindow?.focus(id)
|
axWindow?.focus(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class TrackedWindows {
|
|||||||
Spaces.singleSpace = true
|
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?)] = [:]
|
var windowSpaceMap: [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank?)] = [:]
|
||||||
for (spaceId, spaceIndex) in spaces {
|
for (spaceId, spaceIndex) in spaces {
|
||||||
Spaces.windowsInSpaces([spaceId]).forEach {
|
Spaces.windowsInSpaces([spaceId]).forEach {
|
||||||
@@ -45,7 +45,7 @@ class TrackedWindows {
|
|||||||
Spaces.windowsInSpaces(spaces.map { $0.0 }).enumerated().forEach {
|
Spaces.windowsInSpaces(spaces.map { $0.0 }).enumerated().forEach {
|
||||||
windowSpaceMap[$0.element]!.2 = $0.offset
|
windowSpaceMap[$0.element]!.2 = $0.offset
|
||||||
}
|
}
|
||||||
return windowSpaceMap as! [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)]
|
return windowSpaceMap as! WindowsMap
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sortList() {
|
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) {
|
for cgWindow in CGWindow.windows(.optionAll) {
|
||||||
guard let cgId = cgWindow.value(.number, CGWindowID.self),
|
guard let cgId = cgWindow.value(.number, CGWindowID.self),
|
||||||
let ownerPid = cgWindow.value(.ownerPID, pid_t.self),
|
let ownerPid = cgWindow.value(.ownerPID, pid_t.self),
|
||||||
|
let app = NSRunningApplication(processIdentifier: ownerPid),
|
||||||
cgWindow.isNotMenubarOrOthers(),
|
cgWindow.isNotMenubarOrOthers(),
|
||||||
cgWindow.isReasonablyBig() else {
|
cgWindow.isReasonablyBig() else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
let axApp = cgId.AXUIElementApplication(ownerPid)
|
||||||
let (spaceId, spaceIndex, rank) = windowsMap[cgId] ?? (nil, nil, nil)
|
let (spaceId, spaceIndex, rank) = windowsMap[cgId] ?? (nil, nil, nil)
|
||||||
if let axWindow = cgId.AXUIElement(ownerPid), axWindow.isActualWindow() {
|
if let (isMinimized, isHidden, axWindow) = filter(cgId, spaceId, app, axApp) {
|
||||||
// window is in the current space
|
list.append(TrackedWindow(cgWindow, cgId, app, axApp, isHidden, isMinimized, axWindow, spaceId, spaceIndex, rank))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 WindowRank = Int
|
||||||
|
typealias WindowsMap = [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)]
|
||||||
@@ -9,6 +9,7 @@ class Cell: NSCollectionViewItem {
|
|||||||
var appIcon = NSImageView()
|
var appIcon = NSImageView()
|
||||||
var label = CellTitle(Preferences.fontHeight!)
|
var label = CellTitle(Preferences.fontHeight!)
|
||||||
var minimizedIcon = FontIcon(FontIcon.sfSymbolCircledMinusSign, Preferences.fontIconSize, .white)
|
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 spaceIcon = FontIcon(FontIcon.sfSymbolCircledNumber0, Preferences.fontIconSize, .white)
|
||||||
var openWindow: TrackedWindow?
|
var openWindow: TrackedWindow?
|
||||||
var mouseDownCallback: MouseDownCallback?
|
var mouseDownCallback: MouseDownCallback?
|
||||||
@@ -50,13 +51,14 @@ class Cell: NSCollectionViewItem {
|
|||||||
label.string = element.title
|
label.string = element.title
|
||||||
// workaround: setting string on NSTextView change the font (most likely a Cocoa bug)
|
// workaround: setting string on NSTextView change the font (most likely a Cocoa bug)
|
||||||
label.font = Preferences.font!
|
label.font = Preferences.font!
|
||||||
let fontIconWidth = Spaces.singleSpace && !openWindow!.isMinimized ? 0 : Preferences.fontIconSize + Preferences.interItemPadding
|
hiddenIcon.isHidden = !openWindow!.isHidden
|
||||||
label.textContainer!.size.width = thumbnail.frame.width - Preferences.iconSize! - Preferences.interItemPadding - fontIconWidth
|
|
||||||
minimizedIcon.isHidden = !openWindow!.isMinimized
|
minimizedIcon.isHidden = !openWindow!.isMinimized
|
||||||
spaceIcon.isHidden = openWindow!.isMinimized || Spaces.singleSpace || Preferences.hideSpaceNumberLabels
|
spaceIcon.isHidden = element.spaceIndex == nil || Spaces.singleSpace || Preferences.hideSpaceNumberLabels
|
||||||
if !spaceIcon.isHidden {
|
if !spaceIcon.isHidden {
|
||||||
spaceIcon.setNumber(UInt32(element.spaceIndex!))
|
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.mouseDownCallback = mouseDownCallback
|
||||||
self.mouseMovedCallback = mouseMovedCallback
|
self.mouseMovedCallback = mouseMovedCallback
|
||||||
if view.trackingAreas.count > 0 {
|
if view.trackingAreas.count > 0 {
|
||||||
@@ -92,6 +94,7 @@ class Cell: NSCollectionViewItem {
|
|||||||
hStackView.spacing = Preferences.interItemPadding
|
hStackView.spacing = Preferences.interItemPadding
|
||||||
hStackView.addView(appIcon, in: .leading)
|
hStackView.addView(appIcon, in: .leading)
|
||||||
hStackView.addView(label, in: .leading)
|
hStackView.addView(label, in: .leading)
|
||||||
|
hStackView.addView(hiddenIcon, in: .leading)
|
||||||
hStackView.addView(minimizedIcon, in: .leading)
|
hStackView.addView(minimizedIcon, in: .leading)
|
||||||
hStackView.addView(spaceIcon, in: .leading)
|
hStackView.addView(spaceIcon, in: .leading)
|
||||||
return hStackView
|
return hStackView
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Cocoa
|
|||||||
// see https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/
|
// see https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/
|
||||||
class FontIcon: CellTitle {
|
class FontIcon: CellTitle {
|
||||||
static let sfSymbolCircledMinusSign = ""
|
static let sfSymbolCircledMinusSign = ""
|
||||||
|
static let sfSymbolCircledDotSign = ""
|
||||||
static let sfSymbolCircledNumber0 = ""
|
static let sfSymbolCircledNumber0 = ""
|
||||||
static let sfSymbolCircledNumber10 = ""
|
static let sfSymbolCircledNumber10 = ""
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user