Skip to content

Commit

Permalink
feat: handle hidden app windows (closes #108)
Browse files Browse the repository at this point in the history
  • Loading branch information
louis.pontoise committed Jan 4, 2020
1 parent fdb1327 commit e6e3e87
Show file tree
Hide file tree
Showing 6 changed files with 62 additions and 32 deletions.
11 changes: 11 additions & 0 deletions alt-tab-macos/api-wrappers/AXUIElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions alt-tab-macos/api-wrappers/CGWindowID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
22 changes: 12 additions & 10 deletions alt-tab-macos/logic/TrackedWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
43 changes: 28 additions & 15 deletions alt-tab-macos/logic/TrackedWindows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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() {
Expand All @@ -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))
}
if let (isMinimized, isHidden, axWindow) = filter(cgId, spaceId, app, axApp) {
list.append(TrackedWindow(cgWindow, cgId, app, axApp, isHidden, isMinimized, axWindow, spaceId, spaceIndex, 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 WindowsMap = [CGWindowID: (CGSSpaceID, SpaceIndex, WindowRank)]
9 changes: 6 additions & 3 deletions alt-tab-macos/ui/Cell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions alt-tab-macos/ui/FontIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "􀓵"

Expand Down

0 comments on commit e6e3e87

Please sign in to comment.