Skip to content

Commit

Permalink
fix: show minimized windows (closes #11 closes #62)
Browse files Browse the repository at this point in the history
Also adds big performance improvements to show the thumbnails and focus windows
  • Loading branch information
louis.pontoise committed Dec 20, 2019
1 parent 87647b9 commit 4c08c86
Show file tree
Hide file tree
Showing 19 changed files with 406 additions and 155 deletions.
80 changes: 63 additions & 17 deletions alt-tab-macos.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions alt-tab-macos/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
<string>Copyright © 2019 Pontoise, Louis. All rights reserved.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>UIAppFonts</key>
<array>
<string>SF-Pro-Text-Regular.otf</string>
</array>
<key>LSUIElement</key>
<string>1</string>
</dict>
Expand Down
81 changes: 81 additions & 0 deletions alt-tab-macos/PrivateApisBridge.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// In this .h file, we define symbols of private APIs so we can use these APIs in the project
// see Webkit repo: https://github.com/WebKit/webkit/blob/master/Source/WebCore/PAL/pal/spi/cg/CoreGraphicsSPI.h
// see Hammerspoon issue: https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468
// see Alt-tab-macos issue: https://github.com/lwouis/alt-tab-macos/pull/87#issuecomment-558624755

#import <CoreGraphics/CoreGraphics.h>
#import <CoreImage/CoreImage.h>

typedef uint32_t CGSConnectionID;
typedef uint32_t CGSWindowID;
typedef uint32_t CGSWindowCount;
typedef uint32_t CGSWindowCaptureOptions;
enum {
kCGSWindowCaptureNominalResolution = 0x0200,
kCGSCaptureIgnoreGlobalClipShape = 0x0800,
};

extern CGSConnectionID CGSMainConnectionID(void);

// returns an array of CGImage of the windows which ID is given as `windowList`. `windowList` is supposed to be an array of IDs but in my test on High Sierra, the function ignores other IDs than the first, and always returns the screenshot of the first window in the array
// * performance: the `HW` in the name seems to imply better performance, and it was observed by some contributors that it seems to be faster (see https://github.com/lwouis/alt-tab-macos/issues/45) than other methods
// * minimized windows: the function can return screenshots of minimized windows
// * windows in other spaces: ?
extern CFArrayRef CGSHWCaptureWindowList(CGSConnectionID connectionId, CGSWindowID *windowList, CGSWindowCount windowCount, CGSWindowCaptureOptions options);

// returns the CGImage of the window which ID is given in `wid`
// * performance: it seems that this function performs similarly to public API `CGWindowListCreateImage`
// * minimized windows: the function can return screenshots of minimized windows
// * windows in other spaces: ?
extern CGError CGSCaptureWindowsContentsToRectWithOptions(CGSConnectionID connectionId, CGSWindowID *windowId, bool windowOnly, CGRect rect, CGSWindowCaptureOptions options, CGImageRef *image);

// returns the size of a window
// * performance: it seems that this function is faster than the public API AX calls to get a window bounds
// * minimized windows: ?
// * windows in other spaces: ?
extern CGError SLSGetWindowBounds(CGSConnectionID connectionId, CGSWindowID *windowId, CGRect *frame);

typedef uint32_t SLPSMode;
enum {
kCPSAllWindows = 0x100,
kCPSUserGenerated = 0x200,
kCPSNoWindows = 0x400,
};

// focuses a window
// * performance: faster than AXUIElementPerformAction(kAXRaiseAction)
// * minimized windows: yes
// * windows in other spaces: ?
extern CGError _SLPSSetFrontProcessWithOptions(ProcessSerialNumber *psn, CGSWindowID windowId, SLPSMode mode);

extern CGError SLSGetWindowOwner(CGSConnectionID connectionId, CGSWindowID windowId, CGSConnectionID *windowConnectionId);

extern CGError SLSGetConnectionPSN(CGSConnectionID connectionId, ProcessSerialNumber *psn);

extern CGError SLPSPostEventRecordTo(ProcessSerialNumber *psn, uint8_t *bytes);

// The following function was taken from https://github.com/Hammerspoon/hammerspoon/issues/370#issuecomment-545545468
static void window_manager_make_key_window(ProcessSerialNumber *psn, uint32_t windowId) {
// the information specified in the events below consists of the "special" category, event type, and modifiers,
// basically synthesizing a mouse-down and up event targetted at a specific window of the application,
// but it doesn't actually get treated as a mouse-click normally would.
uint8_t bytes1[0xf8] = {
[0x04] = 0xF8,
[0x08] = 0x01,
[0x3a] = 0x10
};
uint8_t bytes2[0xf8] = {
[0x04] = 0xF8,
[0x08] = 0x02,
[0x3a] = 0x10
};
memcpy(bytes1 + 0x3c, &windowId, sizeof(uint32_t));
memset(bytes1 + 0x20, 0xFF, 0x10);
memcpy(bytes2 + 0x3c, &windowId, sizeof(uint32_t));
memset(bytes2 + 0x20, 0xFF, 0x10);
SLPSPostEventRecordTo(psn, bytes1);
SLPSPostEventRecordTo(psn, bytes2);
}

// returns the window ID of the provided AXUIElement
extern AXError _AXUIElementGetWindow(AXUIElementRef axUiElement, uint32_t *windowId);
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import Foundation

class AccessibilityApis {
static func windows(_ cgOwnerPid: pid_t) -> [AXUIElement] {
if let windows = attribute(AXUIElementCreateApplication(cgOwnerPid), kAXWindowsAttribute, [AXUIElement].self) {
return windows.filter {
// workaround: some apps like chrome use a window to implement the search popover
let windowBounds = value($0, kAXSizeAttribute, NSSize(), .cgSize)!
let isReasonablyBig = windowBounds.width > Preferences.minimumWindowSize && windowBounds.height > Preferences.minimumWindowSize
return isReasonablyBig
}
}
return []
return attribute(AXUIElementCreateApplication(cgOwnerPid), kAXWindowsAttribute, [AXUIElement].self) ?? []
}

static func windowThatMatchCgWindow(_ ownerPid: pid_t, _ cgId: CGWindowID) -> AXUIElement? {
return AccessibilityApis.windows(ownerPid).first(where: { return windowId($0) == cgId })
}

private static func windowId(_ window: AXUIElement) -> CGWindowID {
var id = UInt32(0)
_AXUIElementGetWindow(window, &id)
return id
}

static func rect(_ element: AXUIElement) -> CGRect {
Expand All @@ -29,7 +31,7 @@ class AccessibilityApis {
AXUIElementSetAttributeValue(element, attribute as CFString, AXValueCreate(type, &v)!)
}

private static func value<T>(_ element: AXUIElement, _ key: String, _ target: T, _ type: AXValueType) -> T? {
static func value<T>(_ element: AXUIElement, _ key: String, _ target: T, _ type: AXValueType) -> T? {
if let a = attribute(element, key, AXValue.self) {
var value = target
AXValueGetValue(a, type, &value)
Expand All @@ -38,7 +40,7 @@ class AccessibilityApis {
return nil
}

private static func attribute<T>(_ element: AXUIElement, _ key: String, _ type: T.Type) -> T? {
static func attribute<T>(_ element: AXUIElement, _ key: String, _ type: T.Type) -> T? {
var value: AnyObject?
let result = AXUIElementCopyAttributeValue(element, key as CFString, &value)
if result == .success, let typedValue = value as? T {
Expand Down
28 changes: 28 additions & 0 deletions alt-tab-macos/api-wrappers/CoreGraphicsApis.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Cocoa
import Foundation

class CoreGraphicsApis {
static func windows(_ option: CGWindowListOption) -> [NSDictionary] {
return (CGWindowListCopyWindowInfo([.excludeDesktopElements, option], kCGNullWindowID) as! [NSDictionary])
.filter { return windowIsNotMenubarOrOthers($0) && windowIsReasonablyBig($0) }
}

// workaround: filtering this criteria seems to remove non-windows UI elements
private static func windowIsNotMenubarOrOthers(_ window: NSDictionary) -> Bool {
return value(window, kCGWindowLayer, Int(0)) == 0
}

// workaround: some apps like chrome use a window to implement the search popover
private static func windowIsReasonablyBig(_ window: NSDictionary) -> Bool {
let windowBounds = CGRect(dictionaryRepresentation: value(window, kCGWindowBounds, [:] as CFDictionary))!
return windowBounds.width > Preferences.minimumWindowSize && windowBounds.height > Preferences.minimumWindowSize
}

static func value<T>(_ cgWindow: NSDictionary, _ key: CFString, _ fallback: T) -> T {
return cgWindow[key] as? T ?? fallback
}

static func image(_ windowNumber: CGWindowID) -> CGImage? {
return CGWindowListCreateImage(.null, .optionIncludingWindow, windowNumber, [.boundsIgnoreFraming, .bestResolution])
}
}
File renamed without changes.
70 changes: 70 additions & 0 deletions alt-tab-macos/api-wrappers/PreferredApis.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Cocoa
import Foundation

enum WindowScreenshotApi {
case CGWindowListCreateImage
case CGSHWCaptureWindowList
case CGSCaptureWindowsContentsToRectWithOptions
}

enum WindowDimensionsApi {
case AXValueGetValue
case SLSGetWindowBounds
}

enum FocusWindowApi {
case AXUIElementPerformAction
case _SLPSSetFrontProcessWithOptions
}

let cgsMainConnectionId = CGSMainConnectionID()

// This class wraps different public and private APIs that achieve similar functionality.
// It lets the user pick the API as a parameter, and thus the level of service they want
class PreferredApis {
static func windowScreenshot(_ windowId: CGSWindowID, _ api: WindowScreenshotApi) -> CGImage {
switch api {
case .CGWindowListCreateImage:
return CGWindowListCreateImage(.null, .optionIncludingWindow, windowId, [.boundsIgnoreFraming, .bestResolution])!
case .CGSHWCaptureWindowList:
var windowId_ = windowId
let options = CGSWindowCaptureOptions(kCGSCaptureIgnoreGlobalClipShape | kCGSWindowCaptureNominalResolution)
return (CGSHWCaptureWindowList(cgsMainConnectionId, &windowId_, 1, options)!.takeRetainedValue() as! Array<CGImage>).first!
case .CGSCaptureWindowsContentsToRectWithOptions:
var windowId_ = windowId
var windowImage: Unmanaged<CGImage>?
CGSCaptureWindowsContentsToRectWithOptions(cgsMainConnectionId, &windowId_, true, .zero, (1 << 8), &windowImage)
return windowImage!.takeRetainedValue()
}
}

static func windowDimensions(_ windowId: CGSWindowID?, _ axUiElement: AXUIElement?, _ api: WindowDimensionsApi) -> CGSize {
switch api {
case .AXValueGetValue:
return AccessibilityApis.value(axUiElement!, kAXSizeAttribute, CGSize(), .cgSize)!
case .SLSGetWindowBounds:
var windowId_ = windowId!
var frame = CGRect()
SLSGetWindowBounds(cgsMainConnectionId, &windowId_, &frame);
return frame.size
}
}

static func focusWindow(_ axUiElement: AXUIElement, _ windowId: CGSWindowID?, _ ownerPid: Int32?, _ api: FocusWindowApi) -> Void {
DispatchQueue.global(qos: .userInteractive).async {
switch api {
case .AXUIElementPerformAction:
NSRunningApplication(processIdentifier: ownerPid!)?.activate(options: [.activateIgnoringOtherApps])
AccessibilityApis.focus(axUiElement)
case ._SLPSSetFrontProcessWithOptions:
var elementConnection = UInt32.zero
SLSGetWindowOwner(cgsMainConnectionId, windowId!, &elementConnection)
var psn = ProcessSerialNumber()
SLSGetConnectionPSN(elementConnection, &psn)
_SLPSSetFrontProcessWithOptions(&psn, windowId!, SLPSMode(kCPSUserGenerated))
window_manager_make_key_window(&psn, windowId!)
AccessibilityApis.focus(axUiElement)
}
}
}
}
24 changes: 0 additions & 24 deletions alt-tab-macos/logic/CoreGraphicsApis.swift

This file was deleted.

57 changes: 28 additions & 29 deletions alt-tab-macos/logic/Keyboard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ func listenToGlobalKeyboardEvents(_ delegate: Application) {
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: eventMask,
callback: { (_, _, event, delegate_) -> Unmanaged<CGEvent>? in
let d = Unmanaged<Application>.fromOpaque(delegate_!).takeUnretainedValue()
return keyboardHandler(event, d)
},
callback: keyboardHandler,
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(delegate).toOpaque()))
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
Expand All @@ -29,42 +26,44 @@ func listenToGlobalKeyboardEvents(_ delegate: Application) {
}
}

func keyboardHandler(_ cgEvent: CGEvent, _ delegate: Application) -> Unmanaged<CGEvent>? {
if cgEvent.type == .keyDown || cgEvent.type == .keyUp || cgEvent.type == .flagsChanged {
if let event = NSEvent(cgEvent: cgEvent) {
func consumeEvent(_ fn: @escaping () -> Void) -> Unmanaged<CGEvent>? {
// run app logic on main thread
DispatchQueue.main.async {
fn()
}
// previously focused app should not receive keys
return nil
}

func keyboardHandler(proxy: CGEventTapProxy, type: CGEventType, event_: CGEvent, delegate_: UnsafeMutableRawPointer?) -> Unmanaged<CGEvent>? {
let delegate = Unmanaged<Application>.fromOpaque(delegate_!).takeUnretainedValue()
if type == .keyDown || type == .keyUp || type == .flagsChanged {
if let event = NSEvent(cgEvent: event_) {
let keyDown = event.type == .keyDown
let isTab = event.keyCode == Preferences.tabKeyCode
let isMeta = Preferences.metaKeyCodes!.contains(event.keyCode)
let isRightArrow = event.keyCode == kVK_RightArrow
let isLeftArrow = event.keyCode == kVK_LeftArrow
let isEscape = event.keyCode == kVK_Escape
if event.modifierFlags.contains(Preferences.metaModifierFlag!) {
if keyDown {
if isTab && event.modifierFlags.contains(.shift) {
delegate.showUiOrSelectPrevious()
return nil // previously focused app should not receive keys
} else if isTab {
delegate.showUiOrSelectNext()
return nil // previously focused app should not receive keys
} else if isRightArrow && delegate.appIsBeingUsed {
delegate.cycleSelection(1)
return nil // previously focused app should not receive keys
} else if isLeftArrow && delegate.appIsBeingUsed {
delegate.cycleSelection(-1)
return nil // previously focused app should not receive keys
} else if keyDown && isEscape {
delegate.hideUi()
return nil // previously focused app should not receive keys
}
if event.modifierFlags.contains(Preferences.metaModifierFlag!) && keyDown {
if isTab && event.modifierFlags.contains(.shift) {
return consumeEvent { delegate.showUiOrSelectPrevious() }
} else if isTab {
return consumeEvent { delegate.showUiOrSelectNext() }
} else if isRightArrow && delegate.appIsBeingUsed {
return consumeEvent { delegate.cycleSelection(1) }
} else if isLeftArrow && delegate.appIsBeingUsed {
return consumeEvent { delegate.cycleSelection(-1) }
} else if keyDown && isEscape {
return consumeEvent { delegate.hideUi() }
}
} else if isMeta && !keyDown {
delegate.focusTarget()
return nil // previously focused app should not receive keys
return consumeEvent { delegate.focusTarget() }
}
}
} else if cgEvent.type == .tapDisabledByUserInput || cgEvent.type == .tapDisabledByTimeout {
} else if type == .tapDisabledByUserInput || type == .tapDisabledByTimeout {
CGEvent.tapEnable(tap: eventTap!, enable: true)
}
// focused app will receive the event
return Unmanaged.passRetained(cgEvent)
return Unmanaged.passRetained(event_)
}
40 changes: 40 additions & 0 deletions alt-tab-macos/logic/OpenWindow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Cocoa
import Foundation

class OpenWindow {
var cgWindow: NSDictionary
var ownerPid: Int32
var cgId: CGWindowID
var cgTitle: String
var cgRect: CGRect
var thumbnail: NSImage?
var icon: NSImage?
var app: NSRunningApplication?
var axWindow: AXUIElement?
var isMinimized: Bool

init(_ cgWindow: NSDictionary, _ cgId: CGWindowID, _ isMinimized: Bool, _ axWindow: AXUIElement?) {
self.cgWindow = cgWindow
self.cgId = cgId
self.ownerPid = CoreGraphicsApis.value(cgWindow, kCGWindowOwnerPID, Int32(0))
let cgTitle = CoreGraphicsApis.value(cgWindow, kCGWindowName, "")
let cgOwnerName = CoreGraphicsApis.value(cgWindow, kCGWindowOwnerName, "")
self.cgTitle = cgTitle.isEmpty ? cgOwnerName : cgTitle
self.app = NSRunningApplication(processIdentifier: ownerPid)
self.icon = self.app?.icon
self.cgRect = CGRect(dictionaryRepresentation: cgWindow[kCGWindowBounds] as! NSDictionary)!
let cgImage = PreferredApis.windowScreenshot(cgId, .CGSHWCaptureWindowList)
self.thumbnail = NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
self.axWindow = axWindow
self.isMinimized = isMinimized
}

func focus() {
if axWindow == nil {
axWindow = AccessibilityApis.windowThatMatchCgWindow(ownerPid, cgId)
}
if axWindow != nil {
PreferredApis.focusWindow(axWindow!, cgId, nil, ._SLPSSetFrontProcessWithOptions)
}
}
}
1 change: 1 addition & 0 deletions alt-tab-macos/logic/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Preferences {
static var windowMaterial: NSVisualEffectView.Material = .dark
static var windowPadding: CGFloat = 23
static var interItemPadding: CGFloat = 4
static var minimizedIconSize: CGFloat = 20
static var cellPadding: CGFloat = 6
static var cellBorderWidth: CGFloat?
static var cellCornerRadius: CGFloat?
Expand Down
Loading

0 comments on commit 4c08c86

Please sign in to comment.