From 2b752efb7278d3bc51c014e09b5cea5822e35aaa Mon Sep 17 00:00:00 2001 From: Louis Pontoise Date: Sun, 19 Apr 2020 23:33:00 +0900 Subject: [PATCH] feat: add min/demin window shortcut --- src/api-wrappers/AXUIElement.swift | 15 ++++- src/logic/Application.swift | 4 +- src/logic/Preferences.swift | 2 + src/logic/Window.swift | 12 +++- src/logic/Windows.swift | 59 ++++++++++++------- src/ui/App.swift | 27 +++++++-- .../preferences-window/tabs/GeneralTab.swift | 9 ++- 7 files changed, 95 insertions(+), 33 deletions(-) diff --git a/src/api-wrappers/AXUIElement.swift b/src/api-wrappers/AXUIElement.swift index bd811042..21c8db06 100644 --- a/src/api-wrappers/AXUIElement.swift +++ b/src/api-wrappers/AXUIElement.swift @@ -77,11 +77,24 @@ extension AXUIElement { func closeWindow() { if isFullScreen() { - AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, 0 as CFTypeRef) + AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, false as CFTypeRef) } AXUIElementPerformAction(closeButton(), kAXPressAction as CFString) } + func minDeminWindow() { + if isFullScreen() { + AXUIElementSetAttributeValue(self, kAXFullscreenAttribute as CFString, false as CFTypeRef) + // minimizing is ignored if sent immediatly; we wait for the de-fullscreen animation to be over + DispatchQueues.accessibilityCommands.asyncAfter(deadline: .now() + .milliseconds(1000)) { [weak self] in + guard let self = self else { return } + AXUIElementSetAttributeValue(self, kAXMinimizedAttribute as CFString, true as CFTypeRef) + } + } else { + AXUIElementSetAttributeValue(self, kAXMinimizedAttribute as CFString, !isMinimized() as CFTypeRef) + } + } + func focusWindow() { AXUIElementPerformAction(self, kAXRaiseAction as CFString) } diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 04edc94c..50ff7e30 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -111,7 +111,7 @@ private func eventApplicationActivated(_ app: App, _ element: AXUIElement) { let appFocusedWindow = element.focusedWindow(), let existingIndex = Windows.list.firstIndexThatMatches(appFocusedWindow) else { return } Windows.list.insert(Windows.list.remove(at: existingIndex), at: 0) - app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]]) + app.refreshOpenUi([Windows.list[0], Windows.list[existingIndex]], true) } private func eventApplicationHiddenOrShown(_ app: App, _ element: AXUIElement, _ type: String) { @@ -120,7 +120,7 @@ private func eventApplicationHiddenOrShown(_ app: App, _ element: AXUIElement, _ $0.application.axUiElement!.pid() == element.pid() } windows.forEach { $0.isHidden = type == kAXApplicationHiddenNotification } - app.refreshOpenUi(windows) + app.refreshOpenUi(windows, true) } private func eventWindowCreated(_ app: App, _ element: AXUIElement, _ application: Application) { diff --git a/src/logic/Preferences.swift b/src/logic/Preferences.swift index 68c957a3..1b687dff 100644 --- a/src/logic/Preferences.swift +++ b/src/logic/Preferences.swift @@ -18,6 +18,7 @@ class Preferences { "cancelShortcut": "⎋", "closeWindowShortcut": "W", "quitAppShortcut": "Q", + "minDeminWindowShortcut": "M", "arrowKeysEnabled": true, "mouseHoverEnabled": true, "showMinimizedWindows": true, @@ -55,6 +56,7 @@ class Preferences { static var cancelShortcut: String { defaults.string(forKey: "cancelShortcut")! } static var closeWindowShortcut: String { defaults.string(forKey: "closeWindowShortcut")! } static var quitAppShortcut: String { defaults.string(forKey: "quitAppShortcut")! } + static var minDeminWindowShortcut: String { defaults.string(forKey: "minDeminWindowShortcut")! } static var arrowKeysEnabled: Bool { defaults.bool(forKey: "arrowKeysEnabled") } static var mouseHoverEnabled: Bool { defaults.bool(forKey: "mouseHoverEnabled") } static var showMinimizedWindows: Bool { defaults.bool(forKey: "showMinimizedWindows") } diff --git a/src/logic/Window.swift b/src/logic/Window.swift index b64e6ab1..a8804cb0 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -25,7 +25,9 @@ class Window { ] static func stopSubscriptionRetries(_ notification: String, _ cgWindowId: CGWindowID) { - Windows.windowsInSubscriptionRetryLoop.removeAll { $0 == (String(cgWindowId) + String(notification)) } + Windows.windowsInSubscriptionRetryLoop.removeAll { (subscription: String) -> Bool in + subscription == String(cgWindowId) + notification + } } init(_ axUiElement: AXUIElement, _ application: Application) { @@ -76,6 +78,12 @@ class Window { } } + func minDemin() { + DispatchQueues.accessibilityCommands.async { [weak self] in + self?.axUiElement.minDeminWindow() + } + } + func focus() { // 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 @@ -147,7 +155,7 @@ private func eventWindowMiniaturizedOrDeminiaturized(_ app: App, _ element: AXUI guard let index = Windows.list.firstIndexThatMatches(element) else { return } let window = Windows.list[index] window.isMinimized = type == kAXWindowMiniaturizedNotification - app.refreshOpenUi([window]) + app.refreshOpenUi([window], true) } private func eventWindowTitleChanged(_ app: App, _ element: AXUIElement) { diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index aa749565..d7d33980 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -20,6 +20,10 @@ class Windows { } static func cycleFocusedWindowIndex(_ step: Int) { + updateFocusedWindowIndex(windowIndexAfterCycling(step)) + } + + static func windowIndexAfterCycling(_ step: Int) -> Int { var iterations = 0 var targetIndex = focusedWindowIndex repeat { @@ -27,7 +31,7 @@ class Windows { targetIndex = next < 0 ? list.count + next : next iterations += 1 } while !list[targetIndex].shouldShowTheUser && iterations <= list.count - updateFocusedWindowIndex(targetIndex) + return targetIndex } static func moveFocusedWindowIndexAfterWindowDestroyedInBackground(_ destroyedWindowIndex: Int) { @@ -41,17 +45,20 @@ class Windows { // workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked, // switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually Spaces.refreshCurrentSpaceId() - let spacesMap = Spaces.idsAndIndexes - list.forEachAsync { window in - let spaceIds = window.cgWindowId.spaces() - if spaceIds.count == 1 { - window.spaceId = spaceIds.first! - window.spaceIndex = spacesMap.first { $0.0 == spaceIds.first! }!.1 - } else if spaceIds.count > 1 { - window.spaceId = Spaces.currentSpaceId - window.spaceIndex = Spaces.currentSpaceIndex - window.isOnAllSpaces = true - } + list.forEachAsync { (window: Window) in + updatesWindowSpace(window) + } + } + + static func updatesWindowSpace(_ window: Window) { + let spaceIds = window.cgWindowId.spaces() + if spaceIds.count == 1 { + window.spaceId = spaceIds.first! + window.spaceIndex = Spaces.idsAndIndexes.first { $0.0 == spaceIds.first! }!.1 + } else if spaceIds.count > 1 { + window.spaceId = Spaces.currentSpaceId + window.spaceIndex = Spaces.currentSpaceIndex + window.isOnAllSpaces = true } } @@ -80,18 +87,28 @@ class Windows { } static func refreshWhichWindowsToShowTheUser(_ screen: NSScreen) { - var screenFrameInQuartzCoordinates = screen.frame - screenFrameInQuartzCoordinates.origin.y = NSMaxY(NSScreen.screens[0].frame) - NSMaxY(screen.frame) - let activeApp = NSWorkspace.shared.frontmostApplication - Windows.list.forEach { - $0.shouldShowTheUser = !(!Preferences.showMinimizedWindows && $0.isMinimized) && - !(!Preferences.showHiddenWindows && $0.isHidden) && - !(Preferences.appsToShow == .active && $0.application.runningApplication != activeApp) && - !(Preferences.spacesToShow == .active && $0.spaceId != Spaces.currentSpaceId) && - !(Preferences.screensToShow == .showingAltTab && $0.axUiElement.position().map { p in !screenFrameInQuartzCoordinates.contains(p) } ?? true) + Windows.list.forEach { (window: Window) in + refreshIfWindowShouldBeShownToTheUser(window, screen) } } + static func refreshIfWindowShouldBeShownToTheUser(_ window: Window, _ screen: NSScreen) { + window.shouldShowTheUser = !(!Preferences.showMinimizedWindows && window.isMinimized) && + !(!Preferences.showHiddenWindows && window.isHidden) && + !(Preferences.appsToShow == .active && window.application.runningApplication != NSWorkspace.shared.frontmostApplication) && + !(Preferences.spacesToShow == .active && window.spaceId != Spaces.currentSpaceId) && + !(Preferences.screensToShow == .showingAltTab && !isOnScreen(window, screen)) + } + + static func isOnScreen(_ window: Window, _ screen: NSScreen) -> Bool { + if let position = window.axUiElement.position() { + var screenFrameInQuartzCoordinates = screen.frame + screenFrameInQuartzCoordinates.origin.y = NSMaxY(NSScreen.screens[0].frame) - NSMaxY(screen.frame) + return screenFrameInQuartzCoordinates.contains(position) + } + return true + } + static func refreshAllExistingThumbnails() { refreshAllThumbnails() guard App.app.uiWorkShouldBeDone else { return } diff --git a/src/ui/App.swift b/src/ui/App.swift index 2e50f27f..3d861d96 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -104,6 +104,10 @@ class App: NSApplication, NSApplicationDelegate { Windows.focusedWindow()?.quitApp() } + func minDeminSelectedWindow() { + Windows.focusedWindow()?.minDemin() + } + func focusTarget() { debugPrint("focusTarget") focusSelectedWindow(Windows.focusedWindow()) @@ -148,15 +152,14 @@ class App: NSApplication, NSApplicationDelegate { rebuildUi() } - func refreshOpenUi(_ windowsToRefresh: [Window]? = nil) { + func refreshOpenUi(_ windowsToUpdate: [Window]? = nil, _ updateWindowsInfo: Bool = false) { guard appIsBeingUsed else { return } - windowsToRefresh?.forEach { $0.refreshThumbnail() } - guard uiWorkShouldBeDone else { return } + let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) // workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked, // switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually Spaces.refreshCurrentSpaceId() + refreshSpecificWindows(windowsToUpdate, updateWindowsInfo, currentScreen) guard uiWorkShouldBeDone else { return } - let currentScreen = Screen.preferred() // fix screen between steps since it could change (e.g. mouse moved to another screen) thumbnailsPanel.thumbnailsView.updateItems(currentScreen) guard uiWorkShouldBeDone else { return } thumbnailsPanel.setFrame(thumbnailsPanel.thumbnailsView.frame, display: false) @@ -164,6 +167,22 @@ class App: NSApplication, NSApplicationDelegate { Screen.repositionPanel(thumbnailsPanel, currentScreen, .appleCentered) } + private func refreshSpecificWindows(_ windowsToUpdate: [Window]?, _ updateWindowsInfo: Bool, _ currentScreen: NSScreen) -> ()? { + windowsToUpdate?.forEach { (window: Window) in + guard uiWorkShouldBeDone else { return } + window.refreshThumbnail() + if updateWindowsInfo { + Windows.refreshIfWindowShouldBeShownToTheUser(window, currentScreen) + if !window.shouldShowTheUser && window.cgWindowId == Windows.focusedWindow()!.cgWindowId { + let stepWithClosestWindow = Windows.windowIndexAfterCycling(-1) > Windows.focusedWindowIndex ? 1 : -1 + Windows.cycleFocusedWindowIndex(stepWithClosestWindow) + } else { + Windows.updatesWindowSpace(window) + } + } + } + } + func showUiOrCycleSelection(_ step: Int) { debugPrint("showUiOrCycleSelection", step) if isFirstSummon { diff --git a/src/ui/preferences-window/tabs/GeneralTab.swift b/src/ui/preferences-window/tabs/GeneralTab.swift index b81b0d83..1cd3ef70 100644 --- a/src/ui/preferences-window/tabs/GeneralTab.swift +++ b/src/ui/preferences-window/tabs/GeneralTab.swift @@ -19,6 +19,7 @@ class GeneralTab: NSViewController, PreferencePane { let cancelShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Cancel and hide", comment: ""), "cancelShortcut", Preferences.cancelShortcut, labelPosition: .right) let closeWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Close window", comment: ""), "closeWindowShortcut", Preferences.closeWindowShortcut, labelPosition: .right) let quitAppShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Quit app", comment: ""), "quitAppShortcut", Preferences.quitAppShortcut, labelPosition: .right) + let minDeminWindowShortcut = LabelAndControl.makeLabelWithRecorder(NSLocalizedString("Minimize/Deminimize window", comment: ""), "minDeminWindowShortcut", Preferences.minDeminWindowShortcut, labelPosition: .right) let enableArrows = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Arrow keys", comment: ""), "arrowKeysEnabled", extraAction: GeneralTab.arrowKeysEnabledCallback, labelPosition: .right) let enableMouse = LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Mouse hover", comment: ""), "mouseHoverEnabled", labelPosition: .right) let holdAndPress = StackView(holdShortcut) @@ -29,7 +30,7 @@ class GeneralTab: NSViewController, PreferencePane { let screensToShow = dropdown("screensToShow", ScreensToShowPreference.allCases) let showMinimizedWindows = StackView(LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Minimized", comment: ""), "showMinimizedWindows", labelPosition: .right)) let showHiddenWindows = StackView(LabelAndControl.makeLabelWithCheckbox(NSLocalizedString("Hidden", comment: ""), "showHiddenWindows", labelPosition: .right)) - let shortcuts = StackView([nextWindowShortcut, previousWindowShortcut, cancelShortcut, closeWindowShortcut, quitAppShortcut].map { (view: [NSView]) in StackView(view) }, .vertical) + let shortcuts = StackView([nextWindowShortcut, previousWindowShortcut, cancelShortcut, closeWindowShortcut, quitAppShortcut, minDeminWindowShortcut].map { (view: [NSView]) in StackView(view) }, .vertical) let toShowDropdowns = StackView([appsToShow, spacesToShow, screensToShow], .vertical) let toShowCheckboxes = StackView([showMinimizedWindows, showHiddenWindows], .vertical) let toShowExplanations = LabelAndControl.makeLabel(NSLocalizedString("Show the following windows:", comment: "")) @@ -46,7 +47,7 @@ class GeneralTab: NSViewController, PreferencePane { grid.fit() GeneralTab.shortcutsDependentOnHoldShortcut.append(contentsOf: [enableArrows[0] as! NSControl] + - [nextWindowShortcut, previousWindowShortcut, cancelShortcut, closeWindowShortcut, quitAppShortcut].map { $0[0] as! NSControl }) + [nextWindowShortcut, previousWindowShortcut, cancelShortcut, closeWindowShortcut, quitAppShortcut, minDeminWindowShortcut].map { $0[0] as! NSControl }) GeneralTab.arrowKeysEnabledCallback(enableArrows[0] as! NSControl) startAtLoginCallback(startAtLogin[1] as! NSControl) @@ -62,7 +63,7 @@ class GeneralTab: NSViewController, PreferencePane { removeShortcutIfExists(controlId, type) // remove the previous shortcut shortcutActions[controlId] = ShortcutAction(shortcut: shortcut, actionHandler: { _ in let isShortcutInitiatingTheApp = ["previousWindowShortcut", "nextWindowShortcut"].contains(controlId) - let isShortcutWithUiShown = ["closeWindowShortcut", "quitAppShortcut"].contains(controlId) + let isShortcutWithUiShown = ["←", "→", "closeWindowShortcut", "quitAppShortcut", "minDeminWindowShortcut"].contains(controlId) App.app.uiWorkShouldBeDone = isShortcutInitiatingTheApp || isShortcutWithUiShown if isShortcutInitiatingTheApp { App.app.appIsBeingUsed = true @@ -103,6 +104,8 @@ class GeneralTab: NSViewController, PreferencePane { return { App.app.closeSelectedWindow() } } else if controlId == "quitAppShortcut" { return { App.app.quitSelectedApp() } + } else if controlId == "minDeminWindowShortcut" { + return { App.app.minDeminSelectedWindow() } } return {} }