feat: show apps with no open window (closes #397)

This commit is contained in:
Louis Pontoise authored and lwouis committed 2020-09-05 04:01:32 +09:00
1 parent dc8b363e57
commit f0fa02c4a2
13 files changed
+126 -35

No files matched your search

Binary file not shown.
+3
View File
@@ -130,6 +130,9 @@
/* No comment provided by engineer. */
"Hide app badges:" = "Hide app badges:";
/* No comment provided by engineer. */
"Hide apps with no open window:" = "Hide apps with no open window:";
/* No comment provided by engineer. */
"Hide colored circles on mouse hover:" = "Hide colored circles on mouse hover:";
@@ -131,6 +131,9 @@
/*No comment provided by engineer.*/
"Hide app badges:" = "Hide app badges:";
/*No comment provided by engineer.*/
"Hide apps with no open window:" = "Hide apps with no open window:";
/*No comment provided by engineer.*/
"Hide colored circles on mouse hover:" = "Hide colored circles on mouse hover:";
+1 -1
View File
@@ -4,4 +4,4 @@ set -exu
"$(pipenv --venv)"/bin/pyftsubset resources/SF-Pro-Text-Regular-Full.otf \
--output-file=resources/SF-Pro-Text-Regular.otf \
--text="􀀁􀁎􀁌􀕧􀀸􀀺􀀼􀀾􀁀􀁂􀁄􀑱􀁆􀁈􀁊􀑳􀓵􀓶􀓷􀓸􀓹􀓺􀓻􀓼􀓽􀓾􀓿􀔀􀔁􀔂􀔃􀔄􀔅􀔆􀔇􀔈􀔉􀕬􀀹􀀻􀀽􀀿􀁁􀘘􀁃􀁅􀑲􀁇􀁉􀁋􀑴􀔔􀔕􀔖􀔗􀔘􀔙􀔚􀔛􀔜􀔝􀔞􀔟􀔠􀔡􀔢􀔣􀔤􀔥􀔦􀔧􀔨􀕭"
--text="􀥃􀀁􀁎􀁌􀕧􀀸􀀺􀀼􀀾􀁀􀁂􀁄􀑱􀁆􀁈􀁊􀑳􀓵􀓶􀓷􀓸􀓹􀓺􀓻􀓼􀓽􀓾􀓿􀔀􀔁􀔂􀔃􀔄􀔅􀔆􀔇􀔈􀔉􀕬􀀹􀀻􀀽􀀿􀁁􀘘􀁃􀁅􀑲􀁇􀁉􀁋􀑴􀔔􀔕􀔖􀔗􀔘􀔙􀔚􀔛􀔜􀔝􀔞􀔟􀔠􀔡􀔢􀔣􀔤􀔥􀔦􀔧􀔨􀕭"
+37 -4
View File
@@ -10,6 +10,7 @@ class Application: NSObject {
var isHidden: Bool!
var icon: NSImage?
var dockLabel: String?
var pid: pid_t { runningApplication.processIdentifier }
static func notifications(_ app: NSRunningApplication) -> [String] {
var n = [
@@ -36,8 +37,17 @@ class Application: NSObject {
icon = runningApplication.icon
addAndObserveWindows()
kvObservers = [
runningApplication.observe(\.isFinishedLaunching, options: [.new]) { [weak self] _, _ in self?.addAndObserveWindows() },
runningApplication.observe(\.activationPolicy, options: [.new]) { [weak self] _, _ in self?.addAndObserveWindows() },
runningApplication.observe(\.isFinishedLaunching, options: [.new]) { [weak self] _, _ in
guard let self = self else { return }
self.addAndObserveWindows()
},
runningApplication.observe(\.activationPolicy, options: [.new]) { [weak self] _, _ in
guard let self = self else { return }
if self.runningApplication.activationPolicy != .regular {
self.removeWindowslessAppWindow()
}
self.addAndObserveWindows()
},
]
}
@@ -45,8 +55,15 @@ class Application: NSObject {
debugPrint("Deinit app", runningApplication.bundleIdentifier ?? runningApplication.bundleURL ?? "nil")
}
func removeWindowslessAppWindow() {
if let windowlessAppWindow = Windows.list.firstIndex { $0.isWindowlessApp == true && $0.application.pid == pid } {
Windows.list.remove(at: windowlessAppWindow)
App.app.refreshOpenUi()
}
}
func addAndObserveWindows() {
if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited {
if runningApplication.isFinishedLaunching && runningApplication.activationPolicy != .prohibited && axUiElement == nil {
axUiElement = AXUIElementCreateApplication(runningApplication.processIdentifier)
AXObserverCreate(runningApplication.processIdentifier, axObserverCallback, &axObserver)
debugPrint("Adding app", runningApplication.processIdentifier, runningApplication.bundleIdentifier ?? "nil")
@@ -75,6 +92,14 @@ class Application: NSObject {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.addWindows(windows)
self.addWindowslessAppsIfNeeded()
App.app.refreshOpenUi()
}
} else {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.addWindowslessAppsIfNeeded()
App.app.refreshOpenUi()
}
}
}
@@ -92,7 +117,15 @@ class Application: NSObject {
if App.app.appIsBeingUsed {
Windows.cycleFocusedWindowIndex(windows.count)
}
App.app.refreshOpenUi(windows)
}
func addWindowslessAppsIfNeeded() {
if !Preferences.hideWindowlessApps &&
runningApplication.activationPolicy == .regular &&
!runningApplication.isTerminated &&
(Windows.list.firstIndex { $0.application.pid == pid }) == nil {
Windows.list.insertAndScaleRecycledPool(Window(self), at: Windows.list.count)
}
}
private func observeEvents() {
+5 -5
View File
@@ -10,7 +10,7 @@ class Applications {
guard app.runningApplication.isFinishedLaunching else { continue }
app.observeNewWindows(group)
}
_ = group.wait(wallTimeout: .now() + .seconds(1))
_ = group.wait(wallTimeout: .now() + .seconds(2))
}
static func initialDiscovery() {
@@ -49,13 +49,13 @@ class Applications {
static func removeRunningApplications(_ runningApps: [NSRunningApplication]) {
var windowsOnTheLeftOfFocusedWindow = 0
for runningApp in runningApps {
Applications.list.removeAll(where: { $0.runningApplication.isEqual(runningApp) })
Applications.list.removeAll(where: { $0.pid == runningApp.processIdentifier })
Windows.list.enumerated().forEach { (index, window) in
if window.application.runningApplication.isEqual(runningApp) && index < Windows.focusedWindowIndex {
if window.application.pid == runningApp.processIdentifier && index < Windows.focusedWindowIndex {
windowsOnTheLeftOfFocusedWindow += 1
}
}
Windows.list.removeAll(where: { $0.application.runningApplication.isEqual(runningApp) })
Windows.list.removeAll(where: { $0.application.pid == runningApp.processIdentifier })
}
guard Windows.list.count > 0 else { App.app.hideUi(); return }
if windowsOnTheLeftOfFocusedWindow > 0 {
@@ -67,7 +67,7 @@ class Applications {
static func refreshBadges() {
let group = DispatchGroup()
retryAxCallUntilTimeout(group) {
if let dockPid = (Applications.list.first { $0.runningApplication.bundleIdentifier == "com.apple.dock" }?.runningApplication.processIdentifier),
if let dockPid = (Applications.list.first { $0.runningApplication.bundleIdentifier == "com.apple.dock" }?.pid),
let axList = (try AXUIElementCreateApplication(dockPid).children()?.first { try $0.role() == "AXList" }),
let axAppDockItem = (try axList.children()?.filter { try $0.subrole() == "AXApplicationDockItem" && ($0.appIsRunning() ?? false) }) {
try Applications.list.forEach { app in
+2
View File
@@ -59,6 +59,7 @@ class Preferences {
"maxCellsPerRow": defaultsDependingOnScreenRatio_["maxCellsPerRow"]!,
"shortcutStyle": "0",
"hideAppBadges": "false",
"hideWindowlessApps": "false",
]
// constant values
@@ -98,6 +99,7 @@ class Preferences {
static var hideSpaceNumberLabels: Bool { defaults.bool("hideSpaceNumberLabels") }
static var hideStatusIcons: Bool { defaults.bool("hideStatusIcons") }
static var hideAppBadges: Bool { defaults.bool("hideAppBadges") }
static var hideWindowlessApps: Bool { defaults.bool("hideWindowlessApps") }
static var startAtLogin: Bool { defaults.bool("startAtLogin") }
static var dontShowBlacklist: [String] { blacklistStringToArray(defaults.string("dontShowBlacklist")) }
static var disableShortcutsBlacklist: [String] { blacklistStringToArray(defaults.string("disableShortcutsBlacklist")) }
+30 -9
View File
@@ -1,7 +1,7 @@
import Cocoa
class Window {
var cgWindowId: CGWindowID
var cgWindowId = CGWindowID.max
var title: String!
var thumbnail: NSImage?
var thumbnailFullSize: NSSize?
@@ -10,13 +10,14 @@ class Window {
var isTabbed: Bool = false
var isHidden: Bool { get { application.isHidden } }
var dockLabel: Int? { get { application.dockLabel.flatMap { Int($0) } } }
var isFullscreen: Bool
var isMinimized: Bool
var isOnAllSpaces: Bool
var isFullscreen = false
var isMinimized = false
var isOnAllSpaces = false
var isWindowlessApp = false
var position: CGPoint?
var spaceId: CGSSpaceID
var spaceIndex: SpaceIndex
var axUiElement: AXUIElement
var spaceId = CGSSpaceID.max
var spaceIndex = SpaceIndex.max
var axUiElement: AXUIElement!
var application: Application
var axObserver: AXObserver?
var row: Int?
@@ -43,17 +44,27 @@ class Window {
self.position = position
self.title = bestEffortTitle(axTitle)
self.isTabbed = false
refreshThumbnail()
if !Preferences.hideThumbnails {
refreshThumbnail()
}
application.removeWindowslessAppWindow()
debugPrint("Adding window", cgWindowId, title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil")
observeEvents()
}
init(_ application: Application) {
isWindowlessApp = true
self.application = application
self.title = application.runningApplication.localizedName
debugPrint("Adding app-window", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil")
}
deinit {
debugPrint("Deinit window", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil")
}
private func observeEvents() {
AXObserverCreate(application.runningApplication.processIdentifier, axObserverCallback, &axObserver)
AXObserverCreate(application.pid, axObserverCallback, &axObserver)
guard let axObserver = axObserver else { return }
for notification in Window.notifications {
retryAxCallUntilTimeout { [weak self] in
@@ -84,6 +95,7 @@ class Window {
}
func close() {
if isWindowlessApp { return }
BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in
guard let self = self else { return }
if self.isFullscreen {
@@ -96,6 +108,7 @@ class Window {
}
func minDemin() {
if isWindowlessApp { return }
BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in
guard let self = self else { return }
if self.isFullscreen {
@@ -112,6 +125,7 @@ class Window {
}
func toggleFullscreen() {
if isWindowlessApp { return }
BackgroundWork.accessibilityCommandsQueue.asyncWithCap { [weak self] in
guard let self = self else { return }
self.axUiElement.setAttribute(kAXFullscreenAttribute, !self.isFullscreen)
@@ -140,6 +154,12 @@ class Window {
func focus() {
if application.runningApplication.processIdentifier == ProcessInfo.processInfo.processIdentifier {
App.app.showSecondaryWindow(App.app.window(withWindowNumber: Int(cgWindowId)))
} else if isWindowlessApp {
if let bundleID = application.runningApplication.bundleIdentifier {
NSWorkspace.shared.launchApplication(withBundleIdentifier: bundleID, additionalEventParamDescriptor: nil, launchIdentifier: nil)
} else {
application.runningApplication.activate(options: .activateIgnoringOtherApps)
}
} else {
// 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
@@ -190,3 +210,4 @@ class Window {
return application.runningApplication.localizedName ?? ""
}
}
+9 -7
View File
@@ -122,14 +122,16 @@ class Windows {
static func refreshIfWindowShouldBeShownToTheUser(_ window: Window, _ screen: NSScreen) {
window.shouldShowTheUser =
!(!Preferences.showFullscreenWindows[App.app.shortcutIndex] && window.isFullscreen) &&
!(!Preferences.showMinimizedWindows[App.app.shortcutIndex] && window.isMinimized) &&
!(!Preferences.showHiddenWindows[App.app.shortcutIndex] && window.isHidden) &&
!(window.application.runningApplication.bundleIdentifier.flatMap { Preferences.dontShowBlacklist.contains($0) } ?? false) &&
!(Preferences.appsToShow[App.app.shortcutIndex] == .active && window.application.runningApplication != NSWorkspace.shared.frontmostApplication) &&
!(Preferences.spacesToShow[App.app.shortcutIndex] == .active && window.spaceId != Spaces.currentSpaceId) &&
!(Preferences.screensToShow[App.app.shortcutIndex] == .showingAltTab && !isOnScreen(window, screen)) &&
(Preferences.showTabsAsWindows || !window.isTabbed) &&
!(window.application.runningApplication.bundleIdentifier.flatMap { Preferences.dontShowBlacklist.contains($0) } ?? false)
!(!Preferences.showHiddenWindows[App.app.shortcutIndex] && window.isHidden) &&
((!Preferences.hideWindowlessApps && window.isWindowlessApp) ||
!window.isWindowlessApp &&
!(!Preferences.showFullscreenWindows[App.app.shortcutIndex] && window.isFullscreen) &&
!(!Preferences.showMinimizedWindows[App.app.shortcutIndex] && window.isMinimized) &&
!(Preferences.spacesToShow[App.app.shortcutIndex] == .active && window.spaceId != Spaces.currentSpaceId) &&
!(Preferences.screensToShow[App.app.shortcutIndex] == .showingAltTab && !isOnScreen(window, screen)) &&
(Preferences.showTabsAsWindows || !window.isTabbed))
}
static func isOnScreen(_ window: Window, _ screen: NSScreen) -> Bool {
+9 -7
View File
@@ -28,10 +28,10 @@ func retryAxCallUntilTimeout_(_ group: DispatchGroup?, _ fn: @escaping () throws
}
func handleEvent(_ type: String, _ element: AXUIElement) throws {
debugPrint("Accessibility event", type, try element.title() ?? "nil")
debugPrint("Accessibility event", type, type != kAXFocusedUIElementChangedNotification ? (try element.title() ?? "nil") : "nil")
// events are handled concurrently, thus we check that the app is still running
if let pid = try element.pid(),
try (!(pid == ProcessInfo.processInfo.processIdentifier && element.subrole() == "AXUnknown")) {
try (!(type == kAXWindowCreatedNotification && pid == ProcessInfo.processInfo.processIdentifier && element.subrole() == "AXUnknown")) {
switch type {
case kAXApplicationActivatedNotification: try applicationActivated(element)
case kAXApplicationHiddenNotification,
@@ -57,7 +57,7 @@ private func focusedUiElementChanged(_ element: AXUIElement, _ pid: pid_t) throw
DispatchQueue.main.async {
let windows = Windows.list.filter { w in
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
let isFromApp = w.application.runningApplication.processIdentifier == pid
let isFromApp = w.application.pid == pid
if isFromApp {
// this event is the only opportunity we have to check if a window became a tab, or a tab became a window
let oldIsTabbed = w.isTabbed
@@ -87,12 +87,12 @@ private func applicationActivated(_ element: AXUIElement) throws {
private func applicationHiddenOrShown(_ element: AXUIElement, _ pid: pid_t, _ type: String) throws {
DispatchQueue.main.async {
if let app = (Applications.list.first { $0.runningApplication.processIdentifier == pid }) {
if let app = (Applications.list.first { $0.pid == pid }) {
app.isHidden = type == kAXApplicationHiddenNotification
}
let windows = Windows.list.filter {
// for AXUIElement of apps, CFEqual or == don't work; looks like a Cocoa bug
return $0.application.runningApplication.processIdentifier == pid
return $0.application.pid == pid
}
App.app.refreshOpenUi(windows)
}
@@ -112,7 +112,7 @@ private func windowCreated(_ element: AXUIElement, _ pid: pid_t) throws {
if Windows.list.firstIndexThatMatches(element, wid) == nil,
let runningApp = NSRunningApplication(processIdentifier: pid),
element.isActualWindow(runningApp, wid, isOnNormalLevel, axTitle, subrole, role),
let app = (Applications.list.first { $0.runningApplication.processIdentifier == pid }) {
let app = (Applications.list.first { $0.pid == pid }) {
let window = Window(element, app, wid, axTitle, isFullscreen, isMinimized, position)
Windows.list.insertAndScaleRecycledPool(window, at: 0)
Windows.cycleFocusedWindowIndex(1)
@@ -139,7 +139,7 @@ private func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) throws {
}
} else if let runningApp = NSRunningApplication(processIdentifier: pid),
element.isActualWindow(runningApp, wid, isOnNormalLevel, axTitle, subrole, role),
let app = (Applications.list.first { $0.runningApplication.processIdentifier == pid }) {
let app = (Applications.list.first { $0.pid == pid }) {
Windows.list.insertAndScaleRecycledPool(Window(element, app, wid, axTitle, isFullscreen, isMinimized, position), at: 0)
App.app.refreshOpenUi([Windows.list[0]])
}
@@ -152,7 +152,9 @@ private func windowDestroyed(_ element: AXUIElement) throws {
let wid = try element.cgWindowId()
DispatchQueue.main.async {
guard let existingIndex = Windows.list.firstIndexThatMatches(element, wid) else { return }
let window = Windows.list[existingIndex]
Windows.list.remove(at: existingIndex)
window.application.addWindowslessAppsIfNeeded()
guard Windows.list.count > 0 else { App.app.hideUi(); return }
Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(existingIndex)
App.app.refreshOpenUi()
@@ -11,6 +11,17 @@ enum Symbols: String {
case filledCircled = "􀀁"
case filledCircledNumber0 = "􀀹"
case filledCircledNumber10 = "􀔔"
case newWindow = "􀥃"
}
class FontIcon: BaseLabel {
convenience init(_ symbol: Symbols, _ color: NSColor = .white) {
self.init(symbol.rawValue)
font = NSFont(name: "SF Pro Text", size: Preferences.fontHeight)!
textColor = color
alignment = .center
shadow = ThumbnailView.makeShadow(.darkGray)
}
}
// Font icon using SF Symbols from the SF Pro font from Apple
+15 -2
View File
@@ -20,6 +20,7 @@ class ThumbnailView: NSStackView {
var isHighlighted = false
var shouldShowWindowControls = false
var isShowingWindowControls = false
var windowlessIcon = FontIcon(.newWindow)
convenience init() {
self.init(frame: .zero)
@@ -44,6 +45,7 @@ class ThumbnailView: NSStackView {
setViews([hStackView, thumbnail], in: .leading)
addWindowControls()
addDockLabelIcon()
thumbnail.addSubview(windowlessIcon, positioned: .above, relativeTo: nil)
}
private func addDockLabelIcon() {
@@ -68,7 +70,7 @@ class ThumbnailView: NSStackView {
if let shouldShowWindowControls = shouldShowWindowControls_ {
self.shouldShowWindowControls = shouldShowWindowControls
}
let shouldShow = shouldShowWindowControls && isHighlighted && !Preferences.hideColoredCircles
let shouldShow = shouldShowWindowControls && isHighlighted && !Preferences.hideColoredCircles && !window_!.isWindowlessApp
if isShowingWindowControls != shouldShow {
isShowingWindowControls = shouldShow
[closeIcon, minimizeIcon, maximizeIcon].forEach { $0.isHidden = !shouldShow }
@@ -144,7 +146,18 @@ class ThumbnailView: NSStackView {
assignIfDifferent(&frame.size.height, newHeight)
let fontIconWidth = CGFloat([fullscreenIcon, minimizedIcon, hiddenIcon, spaceIcon].filter { !$0.isHidden }.count) * (Preferences.fontIconSize + Preferences.intraCellPadding)
assignIfDifferent(&label.textContainer!.size.width, frame.width - Preferences.iconSize - Preferences.intraCellPadding * 3 - fontIconWidth)
assignIfDifferent(&subviews.first!.frame.size, frame.size)
assignIfDifferent(&windowlessIcon.isHidden, !element.isWindowlessApp)
if element.isWindowlessApp {
let maxWidth = (ThumbnailView.widthMin(screen) - Preferences.intraCellPadding * 2).rounded()
let maxHeight = ((ThumbnailView.height(screen) - hStackView.fittingSize.height) - Preferences.intraCellPadding * 2).rounded()
// heuristic to determine font size based on bounding box
let fontSize = (min(maxWidth, maxHeight) * 0.6).rounded()
// 1.25 is a heuristic to fit the SF Symbol into the bounding box
windowlessIcon.frame.size = CGSize(width: maxWidth, height: (fontSize * 1.25).rounded())
windowlessIcon.font = NSFont(name: windowlessIcon.font!.fontName, size: fontSize)
// 2.5 is a heuristic to have a _perceived_ vertical alignment on this particular icon
windowlessIcon.frame.origin = CGPoint(x: (-maxWidth / 2).rounded(), y: (-maxHeight / 2 - windowlessIcon.frame.size.height / 2.5).rounded())
}
self.mouseUpCallback = { () -> Void in App.app.focusSelectedWindow(element) }
self.mouseMovedCallback = { () -> Void in Windows.updateFocusedWindowIndex(index) }
// force a display to avoid flickering; see https://github.com/lwouis/alt-tab-macos/issues/197
@@ -20,6 +20,7 @@ class AppearanceTab {
LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Show standard tabs as windows:", comment: ""), "showTabsAsWindows"),
LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hide colored circles on mouse hover:", comment: ""), "hideColoredCircles"),
LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hide app badges:", comment: ""), "hideAppBadges"),
LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hide apps with no open window:", comment: ""), "hideWindowlessApps"),
])
grid.column(at: 0).xPlacement = .trailing
grid.rowAlignment = .lastBaseline