From b51ff6583d9eb8fecea82ef989dbd3a9bbfa08c5 Mon Sep 17 00:00:00 2001 From: lwouis Date: Tue, 31 Dec 2024 11:14:54 +0100 Subject: [PATCH] feat: switcher displays faster; other performance improvements --- alt-tab-macos.xcodeproj/project.pbxproj | 2 - config/base.xcconfig | 2 +- src/api-wrappers/CGWindowID.swift | 2 +- src/api-wrappers/HelperExtensions.swift | 61 +- src/logic/Application.swift | 11 +- src/logic/Applications.swift | 33 +- src/logic/BackgroundWork.swift | 7 +- src/logic/Spaces.swift | 5 - src/logic/Window.swift | 51 +- src/logic/Windows.swift | 113 +++- src/logic/events/AccessibilityEvents.swift | 18 +- src/logic/events/ScreensEvents.swift | 2 +- src/logic/events/SpacesEvents.swift | 2 +- src/ui/App.swift | 60 +- .../generic-components/TableGroupView.swift | 8 +- .../main-window/ThumbnailFontIconView.swift | 44 +- src/ui/main-window/ThumbnailTitleView.swift | 8 +- src/ui/main-window/ThumbnailView.swift | 618 +++++++++--------- .../preferences-window/LabelAndControl.swift | 5 +- .../tabs/appearance/AppearanceTab.swift | 4 +- 20 files changed, 563 insertions(+), 493 deletions(-) diff --git a/alt-tab-macos.xcodeproj/project.pbxproj b/alt-tab-macos.xcodeproj/project.pbxproj index 2cd6570b..b2458a5b 100644 --- a/alt-tab-macos.xcodeproj/project.pbxproj +++ b/alt-tab-macos.xcodeproj/project.pbxproj @@ -3140,7 +3140,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 5.0; }; name = Release; }; @@ -3206,7 +3205,6 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; }; name = Debug; }; diff --git a/config/base.xcconfig b/config/base.xcconfig index ec3051ec..67190791 100644 --- a/config/base.xcconfig +++ b/config/base.xcconfig @@ -3,7 +3,7 @@ PRODUCT_NAME = AltTab PRODUCT_BUNDLE_IDENTIFIER = com.lwouis.alt-tab-macos MACOSX_DEPLOYMENT_TARGET = 10.12 -SWIFT_VERSION = 5.8 +SWIFT_VERSION = 5.8 // xcode 14.2 translates this to 5.0; xcode 16.2 as 5.8 SWIFT_TREAT_WARNINGS_AS_ERRORS = YES INFOPLIST_FILE = Info.plist CODE_SIGN_ENTITLEMENTS = alt_tab_macos.entitlements diff --git a/src/api-wrappers/CGWindowID.swift b/src/api-wrappers/CGWindowID.swift index ca645b86..58e75da8 100644 --- a/src/api-wrappers/CGWindowID.swift +++ b/src/api-wrappers/CGWindowID.swift @@ -15,7 +15,7 @@ extension CGWindowID { return CGSCopySpacesForWindows(cgsMainConnectionId, CGSSpaceMask.all.rawValue, [self] as CFArray) as! [CGSSpaceID] } - func screenshot(_ bestResolution: Bool = false) -> CGImage? { + func screenshot(_ bestResolution: Bool) -> CGImage? { // CGSHWCaptureWindowList var windowId_ = self let list = CGSHWCaptureWindowList(cgsMainConnectionId, &windowId_, 1, [.ignoreGlobalClipShape, bestResolution ? .bestResolution : .nominalResolution]).takeRetainedValue() as! [CGImage] diff --git a/src/api-wrappers/HelperExtensions.swift b/src/api-wrappers/HelperExtensions.swift index 361e7182..f7db63d2 100644 --- a/src/api-wrappers/HelperExtensions.swift +++ b/src/api-wrappers/HelperExtensions.swift @@ -96,24 +96,46 @@ extension NSView { anchor.constraint(equalToConstant: constant).isActive = true } } + + func centerFrameInParent(x: Bool = false, y: Bool = false) { + let selfSize = (self is NSTextField) ? (self as! NSTextField).cell!.cellSize : frame.size + let superviewSize = (superview! is NSTextField) ? (superview! as! NSTextField).cell!.cellSize : superview!.frame.size + if (x) { + frame.origin.x = ((superviewSize.width - selfSize.width) / 2).rounded() + } + if (y) { + let diff = superviewSize.height - selfSize.height + // if there is no perfect centering, we biais top, as it's more aesthetic for ThumbnailView.label + let diffWithBiasTop = diff.truncatingRemainder(dividingBy: 2) == 0 ? diff : diff - 1 + frame.origin.y = (diffWithBiasTop / 2).rounded() + } + } + + func setSubviews(_ views: [NSView]) { + for view in views { + normalizeSubview(view) + } + subviews = views + } + + func addSubviews(_ views: [NSView]) { + for view in views { + normalizeSubview(view) + } + subviews = subviews + views + } + + func setSubviewAbove(_ view: NSView) { + normalizeSubview(view) + addSubview(view, positioned: .above, relativeTo: nil) + } + + private func normalizeSubview(_ view: NSView) { + view.translatesAutoresizingMaskIntoConstraints = false + } } extension Collection { - // forEach with each iteration run concurrently on the global queue - func forEachAsync(fn: @escaping (Element) -> Void) { - let group = DispatchGroup() - for element in self { - backgroundWorkGlobalSemaphore.wait() - BackgroundWork.mainQueueConcurrentWorkQueue.async(group: group) { - group.enter() - fn(element) - backgroundWorkGlobalSemaphore.signal() - group.leave() - } - } - group.wait() - } - subscript(safe index: Index) -> Element? { return indices.contains(index) ? self[index] : nil } @@ -199,6 +221,10 @@ extension NSImage { img.unlockFocus() return img } + + static func fromCgImage(_ cgImage: CGImage) -> NSImage { + return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + } } extension pid_t { @@ -257,6 +283,11 @@ class ModifierFlags { } extension NSPoint { + static func += (lhs: inout NSPoint, rhs: NSPoint) { + lhs.x += rhs.x + lhs.y += rhs.y + } + static func + (lhs: NSPoint, rhs: NSPoint) -> NSPoint { return NSPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) } diff --git a/src/logic/Application.swift b/src/logic/Application.swift index 0ce4c109..d46c3a22 100644 --- a/src/logic/Application.swift +++ b/src/logic/Application.swift @@ -46,7 +46,7 @@ class Application: NSObject { func removeWindowslessAppWindow() { if let windowlessAppWindow = (Windows.list.firstIndex { $0.isWindowlessApp == true && $0.application.pid == pid }) { Windows.list.remove(at: windowlessAppWindow) - App.app.refreshOpenUi() + App.app.refreshOpenUi([]) } } @@ -98,8 +98,9 @@ class Application: NSObject { if (!atLeastOneActualWindow) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - let window = self.addWindowslessAppsIfNeeded() - App.app.refreshOpenUi(window) + if self.addWindowlessWindowIfNeeded() != nil { + App.app.refreshOpenUi([]) + } } // workaround: some apps launch but take a while to create their window(s) // initial windows don't trigger a windowCreated notification, so we won't get notified @@ -113,14 +114,14 @@ class Application: NSObject { } } - func addWindowslessAppsIfNeeded() -> [Window]? { + func addWindowlessWindowIfNeeded() -> Window? { if !Preferences.hideWindowlessApps && runningApplication.activationPolicy == .regular && !runningApplication.isTerminated && (Windows.list.firstIndex { $0.application.pid == pid }) == nil { let window = Window(self) Windows.appendAndUpdateFocus(window) - return [window] + return window } return nil } diff --git a/src/logic/Applications.swift b/src/logic/Applications.swift index 06e1a133..205890af 100644 --- a/src/logic/Applications.swift +++ b/src/logic/Applications.swift @@ -56,26 +56,35 @@ class Applications { } } - static func removeRunningApplications(_ runningApps: [NSRunningApplication]) { + static func removeRunningApplications(_ terminatingApps: [NSRunningApplication]) { + let existingAppsToRemove = list.filter { app in terminatingApps.contains { tApp in app.runningApplication.isEqual(tApp) } } + let existingWindowstoRemove = Windows.list.filter { window in terminatingApps.contains { tApp in window.application.runningApplication.isEqual(tApp) } } + if existingAppsToRemove.isEmpty && existingWindowstoRemove.isEmpty { return } var windowsOnTheLeftOfFocusedWindow = 0 - for runningApp in runningApps { - // comparing pid here can fail here, as it can be already nil; we use isEqual here to avoid the issue - Applications.list.removeAll { $0.runningApplication.isEqual(runningApp) } - Windows.list.enumerated().forEach { (index, window) in - if window.application.runningApplication.isEqual(runningApp) && index < Windows.focusedWindowIndex && window.shouldShowTheUser { + for tApp in terminatingApps { + for (index, window) in Windows.list.enumerated() { + if window.application.runningApplication.isEqual(tApp) + && index < Windows.focusedWindowIndex && window.shouldShowTheUser { windowsOnTheLeftOfFocusedWindow += 1 } } - Windows.list.removeAll { $0.application.runningApplication.isEqual(runningApp) } + // comparing pid here can fail here, as it can be already nil; we use isEqual here to avoid the issue + Applications.list.removeAll { $0.runningApplication.isEqual(tApp) } + Windows.list.removeAll { $0.application.runningApplication.isEqual(tApp) } } - if Windows.list.count == 0 { App.app.hideUi(); return } - if windowsOnTheLeftOfFocusedWindow > 0 { - Windows.cycleFocusedWindowIndex(-windowsOnTheLeftOfFocusedWindow) + if Windows.list.count == 0 { + App.app.hideUi() + } else { + if windowsOnTheLeftOfFocusedWindow > 0 { + Windows.cycleFocusedWindowIndex(-windowsOnTheLeftOfFocusedWindow) + } + if !existingWindowstoRemove.isEmpty { + App.app.refreshOpenUi([]) + } } - App.app.refreshOpenUi() } - static func refreshBadges() { + static func refreshBadgesAsync() { if !App.app.appIsBeingUsed || Preferences.hideAppBadges { return } retryAxCallUntilTimeout { if let dockPid = (list.first { $0.runningApplication.bundleIdentifier == "com.apple.dock" }?.pid), diff --git a/src/logic/BackgroundWork.swift b/src/logic/BackgroundWork.swift index 3a2392a9..fcb1555a 100644 --- a/src/logic/BackgroundWork.swift +++ b/src/logic/BackgroundWork.swift @@ -2,7 +2,7 @@ import Foundation // queues and dedicated threads to observe background events such as keyboard inputs, or accessibility events class BackgroundWork { - static var mainQueueConcurrentWorkQueue: DispatchQueue! + static var screenshotsQueue: DispatchQueue! static var accessibilityCommandsQueue: DispatchQueue! static var axCallsQueue: DispatchQueue! static var crashReportsQueue: DispatchQueue! @@ -11,11 +11,12 @@ class BackgroundWork { static var systemPermissionsThread: BackgroundThreadWithRunLoop! static var repeatingKeyThread: BackgroundThreadWithRunLoop! static var missionControlThread: BackgroundThreadWithRunLoop! + static let screenshotsDispatchGroup = DispatchGroup() // swift static variables are lazy; we artificially force the threads to init static func start() { - // TODO: clarify how this works - mainQueueConcurrentWorkQueue = DispatchQueue.globalConcurrent("mainQueueConcurrentWorkQueue", .userInteractive) + // screenshots are taken off the main thread, concurrently + screenshotsQueue = DispatchQueue.globalConcurrent("screenshotsQueue", .userInteractive) // calls to act on windows (e.g. AXUIElementSetAttributeValue, AXUIElementPerformAction) are done off the main thread accessibilityCommandsQueue = DispatchQueue.globalConcurrent("accessibilityCommandsQueue", .userInteractive) // calls to the AX APIs are blocking. We dispatch those on a globalConcurrent queue diff --git a/src/logic/Spaces.swift b/src/logic/Spaces.swift index a2e1defd..d3328f64 100644 --- a/src/logic/Spaces.swift +++ b/src/logic/Spaces.swift @@ -30,11 +30,6 @@ class Spaces { updateCurrentSpace() } - static func refreshSpacesAndWindows() { - Spaces.refresh() - App.app.refreshOpenUi(Windows.list) - } - private static func updateCurrentSpace() { // it seems that in some rare scenarios, some of these values are nil; we wrap to avoid crashing if let mainScreen = NSScreen.main, diff --git a/src/logic/Window.swift b/src/logic/Window.swift index be065194..4619b57d 100644 --- a/src/logic/Window.swift +++ b/src/logic/Window.swift @@ -7,7 +7,6 @@ class Window { var creationOrder = Int.zero var title: String! var thumbnail: NSImage? - var thumbnailFullSize: NSSize? var icon: NSImage? { get { application.icon } } var shouldShowTheUser = true var isTabbed: Bool = false @@ -49,9 +48,6 @@ class Window { self.title = bestEffortTitle(axTitle) Window.globalCreationCounter += 1 self.creationOrder = Window.globalCreationCounter - if !Appearance.hideThumbnails { - refreshThumbnail() - } application.removeWindowslessAppWindow() checkIfFocused(application, wid) Logger.debug("Adding window", cgWindowId ?? "nil", title ?? "nil", application.runningApplication.bundleIdentifier ?? "nil") @@ -100,23 +96,26 @@ class Window { CFRunLoopAddSource(BackgroundWork.accessibilityEventsThread.runLoop, AXObserverGetRunLoopSource(axObserver), .commonModes) } - private func screenshot(_ bestResolution: Bool = false) -> NSImage? { - guard !isWindowlessApp, let cgWindowId = cgWindowId, cgWindowId != CGWindowID(bitPattern: -1), let cgImage = cgWindowId.screenshot(bestResolution) else { - return nil - } - return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) - } - - func refreshThumbnail() { - guard let screenshot = screenshot() else { - return - } + func refreshThumbnail(_ screenshot: NSImage?) { thumbnail = screenshot - thumbnailFullSize = thumbnail!.size + if App.app.appIsBeingUsed && shouldShowTheUser { + if let index = (Windows.list.firstIndex { $0.cgWindowId == cgWindowId }) { + let view = ThumbnailsView.recycledViews[index] + if !view.thumbnail.isHidden { + view.thumbnail.image = thumbnail + let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail, NSScreen.preferred(), false) + view.thumbnail.image?.size = thumbnailSize + view.thumbnail.frame.size = thumbnailSize + } + } + } } func getPreview() -> NSImage? { - return screenshot(true) + if let cgImage = cgWindowId?.screenshot(true) { + return NSImage.fromCgImage(cgImage) + } + return nil } func canBeClosed() -> Bool { @@ -240,24 +239,6 @@ class Window { return application.runningApplication.localizedName ?? "" } - func updatesWindowSpace() { - // macOS bug: if you tab a window, then move the tab group to another space, other tabs from the tab group will stay on the current space - // you can use the Dock to focus one of the other tabs and it will teleport that tab in the current space, proving that it's a macOS bug - // note: for some reason, it behaves differently if you minimize the tab group after moving it to another space - if let cgWindowId = cgWindowId { - let spaceIds = cgWindowId.spaces() - if spaceIds.count == 1 { - spaceId = spaceIds.first! - spaceIndex = Spaces.idsAndIndexes.first { $0.0 == spaceIds.first! }!.1 - isOnAllSpaces = false - } else if spaceIds.count > 1 { - spaceId = Spaces.currentSpaceId - spaceIndex = Spaces.currentSpaceIndex - isOnAllSpaces = true - } - } - } - func isOnScreen(_ screen: NSScreen) -> Bool { if NSScreen.screensHaveSeparateSpaces { if let screenUuid = screen.uuid(), let screenSpaces = Spaces.screenSpacesMap[screenUuid] { diff --git a/src/logic/Windows.swift b/src/logic/Windows.swift index 8b53fab2..32b3b7c3 100644 --- a/src/logic/Windows.swift +++ b/src/logic/Windows.swift @@ -223,22 +223,18 @@ class Windows { /// tabs detection is a flaky work-around the lack of public API to observe OS tabs /// see: https://github.com/lwouis/alt-tab-macos/issues/1540 - static func detectTabbedWindows() { - lazy var cgsWindowIds = Spaces.windowsInSpaces(Spaces.idsAndIndexes.map { $0.0 }) - lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(Spaces.idsAndIndexes.map { $0.0 }, false) - list.forEach { - if let cgWindowId = $0.cgWindowId { - if $0.isMinimized || $0.isHidden { - if #available(macOS 13.0, *) { - // not exact after window merging - $0.isTabbed = !cgsWindowIds.contains(cgWindowId) - } else { - // not known - $0.isTabbed = false - } + private static func detectTabbedWindows(_ window: Window, _ cgsWindowIds: [CGWindowID], _ visibleCgsWindowIds: [CGWindowID]) { + if let cgWindowId = window.cgWindowId { + if window.isMinimized || window.isHidden { + if #available(macOS 13.0, *) { + // not exact after window merging + window.isTabbed = !cgsWindowIds.contains(cgWindowId) } else { - $0.isTabbed = !visibleCgsWindowIds.contains(cgWindowId) + // not known + window.isTabbed = false } + } else { + window.isTabbed = !visibleCgsWindowIds.contains(cgWindowId) } } } @@ -262,30 +258,85 @@ class Windows { } } - static func refreshThumbnailsAsync(_ screen: NSScreen, _ currentIndex: Int) { - DispatchQueue.main.async { - if !App.app.appIsBeingUsed || Appearance.hideThumbnails { return } - BackgroundWork.mainQueueConcurrentWorkQueue.async { - if currentIndex < list.count { - let window = list[currentIndex] - if window.shouldShowTheUser && !window.isWindowlessApp { - window.refreshThumbnail() - } - refreshThumbnailsAsync(screen, currentIndex + 1) - } else { - DispatchQueue.main.async { App.app.refreshOpenUi() } + static func updatesBeforeShowing(_ screen: NSScreen) -> Bool { + if list.count == 0 || MissionControl.state() == .showAllWindows || MissionControl.state() == .showFrontWindows { return false } + // TODO: find a way to update space info when spaces are changed, instead of on every trigger + // workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked, + // switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually + Spaces.refresh() + let spaceIdsAndIndexes = Spaces.idsAndIndexes.map { $0.0 } + lazy var cgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes) + lazy var visibleCgsWindowIds = Spaces.windowsInSpaces(spaceIdsAndIndexes, false) + for window in list { + // TODO: can the CGS call inside detectTabbedWindows introduce latency when WindowServer is busy? + detectTabbedWindows(window, cgsWindowIds, visibleCgsWindowIds) + updatesWindowSpace(window) + refreshIfWindowShouldBeShownToTheUser(window, screen) + } + refreshWhichWindowsToShowTheUser(screen) + reorderList() + if (!list.contains { $0.shouldShowTheUser }) { return false } + return true + } + + private static func updatesWindowSpace(_ window: Window) { + // macOS bug: if you tab a window, then move the tab group to another space, other tabs from the tab group will stay on the current space + // you can use the Dock to focus one of the other tabs and it will teleport that tab in the current space, proving that it's a macOS bug + // note: for some reason, it behaves differently if you minimize the tab group after moving it to another space + if let cgWindowId = window.cgWindowId { + let spaceIds = cgWindowId.spaces() + if spaceIds.count == 1 { + window.spaceId = spaceIds.first! + window.spaceIndex = Spaces.idsAndIndexes.first { $0.0 == spaceIds.first! }!.1 + window.isOnAllSpaces = false + } else if spaceIds.count > 1 { + window.spaceId = Spaces.currentSpaceId + window.spaceIndex = Spaces.currentSpaceIndex + window.isOnAllSpaces = true + } + } + } + + // dispatch screenshot requests off the main-thread, then wait for completion + static func refreshThumbnails(_ windows: [Window], _ bestResolution: Bool, _ onlyUpdateScreenshots: Bool) { + if Appearance.hideThumbnails { return } + var eligibleWindows = [Window]() + for window in windows { + if !window.isWindowlessApp, let cgWindowId = window.cgWindowId, cgWindowId != CGWindowID(bitPattern: -1) { + eligibleWindows.append(window) + } + } + if eligibleWindows.isEmpty { return } + screenshotEligibleWindowsAndRefreshUi(eligibleWindows, bestResolution, onlyUpdateScreenshots) + } + + private static func screenshotEligibleWindowsAndRefreshUi(_ eligibleWindows: [Window], _ bestResolution: Bool, _ onlyUpdateScreenshots: Bool) { + eligibleWindows.forEach { _ in BackgroundWork.screenshotsDispatchGroup.enter() } + for window in eligibleWindows { + BackgroundWork.screenshotsQueue.async { [weak window] in + backgroundWorkGlobalSemaphore.wait() + defer { + backgroundWorkGlobalSemaphore.signal() + BackgroundWork.screenshotsDispatchGroup.leave() } + if let cgImage = window?.cgWindowId?.screenshot(bestResolution) { + DispatchQueue.main.async { [weak window] in + window?.refreshThumbnail(NSImage.fromCgImage(cgImage)) + } + } + } + } + if !onlyUpdateScreenshots { + BackgroundWork.screenshotsDispatchGroup.notify(queue: DispatchQueue.main) { + App.app.refreshOpenUi([]) } } } static func refreshWhichWindowsToShowTheUser(_ screen: NSScreen) { - Windows.list.forEach { (window: Window) in - refreshIfWindowShouldBeShownToTheUser(window, screen) - } if Preferences.onlyShowApplications() { // Group windows by application and select the optimal main window - let windowsGroupedByApp = Dictionary(grouping: Windows.list) { $0.application.pid } + let windowsGroupedByApp = Dictionary(grouping: list) { $0.application.pid } windowsGroupedByApp.forEach { (app, windows) in if windows.count > 1, let mainWindow = selectMainWindow(windows) { windows.forEach { window in @@ -298,7 +349,7 @@ class Windows { } } - static func refreshIfWindowShouldBeShownToTheUser(_ window: Window, _ screen: NSScreen) { + private static func refreshIfWindowShouldBeShownToTheUser(_ window: Window, _ screen: NSScreen) { window.shouldShowTheUser = !(window.application.runningApplication.bundleIdentifier.flatMap { id in Preferences.blacklist.contains { diff --git a/src/logic/events/AccessibilityEvents.swift b/src/logic/events/AccessibilityEvents.swift index 96972d49..2fb07b2b 100644 --- a/src/logic/events/AccessibilityEvents.swift +++ b/src/logic/events/AccessibilityEvents.swift @@ -42,9 +42,10 @@ fileprivate func applicationActivated(_ element: AXUIElement, _ pid: pid_t) thro let window = (appFocusedWindow != nil && wid != nil) ? Windows.updateLastFocus(appFocusedWindow!, wid!)?.first : nil app.focusedWindow = window App.app.checkIfShortcutsShouldBeDisabled(window, app.runningApplication) - App.app.refreshOpenUi(window != nil ? [window!] : nil) + App.app.refreshOpenUi(window != nil ? [window!] : []) } } + } fileprivate func applicationHiddenOrShown(_ pid: pid_t, _ type: String) throws { @@ -99,6 +100,11 @@ fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) thro let position = try element.position() let size = try element.size() DispatchQueue.main.async { + // if the window is shown by alt-tab, we mark her as focused for this app + // this avoids issues with dialogs, quicklook, etc (see scenarios from #1044 and #2003) + if let w = (Windows.list.first { $0.isEqualRobust(element, wid) }) { + Applications.find(pid)?.focusedWindow = w + } if let windows = Windows.updateLastFocus(element, wid) { App.app.refreshOpenUi(windows) } else if AXUIElement.isActualWindow(runningApp, wid, level, axTitle, subrole, role, size), @@ -107,11 +113,6 @@ fileprivate func focusedWindowChanged(_ element: AXUIElement, _ pid: pid_t) thro Windows.appendAndUpdateFocus(window) App.app.refreshOpenUi([window]) } - // if the window is shown by alt-tab, we mark her as focused for this app - // this avoids issues with dialogs, quicklook, etc (see scenarios from #1044 and #2003) - if let w = (Windows.list.first { $0.isEqualRobust(element, wid) }) { - Applications.find(pid)?.focusedWindow = w - } } } DispatchQueue.main.async { @@ -137,13 +138,12 @@ fileprivate func windowDestroyed(_ element: AXUIElement, _ pid: pid_t) throws { if let index = (Windows.list.firstIndex { $0.isEqualRobust(element, wid) }) { let window = Windows.list[index] Windows.removeAndUpdateFocus(window) - let windowlessApp = window.application.addWindowslessAppsIfNeeded() - if windowlessApp != nil { + if window.application.addWindowlessWindowIfNeeded() != nil { Applications.find(pid)?.focusedWindow = nil } if Windows.list.count > 0 { Windows.moveFocusedWindowIndexAfterWindowDestroyedInBackground(index) - App.app.refreshOpenUi(windowlessApp) + App.app.refreshOpenUi([]) } else { App.app.hideUi() } diff --git a/src/logic/events/ScreensEvents.swift b/src/logic/events/ScreensEvents.swift index d57345cb..d8405e06 100644 --- a/src/logic/events/ScreensEvents.swift +++ b/src/logic/events/ScreensEvents.swift @@ -8,7 +8,7 @@ class ScreensEvents { @objc private static func handleEvent(_ notification: Notification) { Logger.debug(notification.name.rawValue) // a screen added or removed can shuffle windows around Spaces; we refresh them - Spaces.refreshSpacesAndWindows() + App.app.refreshOpenUi(Windows.list) Logger.info("screens", NSScreen.screens.map { ($0.uuid() ?? "nil" as CFString, $0.frame) }) Logger.info("spaces", Spaces.screenSpacesMap) Logger.info("current space", Spaces.currentSpaceIndex, Spaces.currentSpaceId) diff --git a/src/logic/events/SpacesEvents.swift b/src/logic/events/SpacesEvents.swift index d9fb3872..c172e040 100644 --- a/src/logic/events/SpacesEvents.swift +++ b/src/logic/events/SpacesEvents.swift @@ -8,7 +8,7 @@ class SpacesEvents { @objc private static func handleEvent(_ notification: Notification) { Logger.debug(notification.name.rawValue) // if UI was kept open during Space transition, the Spaces may be obsolete; we refresh them - Spaces.refreshSpacesAndWindows() + App.app.refreshOpenUi(Windows.list) Logger.info("current space", Spaces.currentSpaceIndex, Spaces.currentSpaceId) // from macos 12.2 beta onwards, we can't get other-space windows; grabbing windows when switching spaces mitigates the issue // also, updating windows on Space transition works around an issue with Safari where its fullscreen windows spawn not in fullscreen. diff --git a/src/ui/App.swift b/src/ui/App.swift index cb514a70..a89c746c 100644 --- a/src/ui/App.swift +++ b/src/ui/App.swift @@ -250,17 +250,17 @@ class App: AppCenterApplication, NSApplicationDelegate { CGWarpMouseCursorPosition(point) } - func refreshOpenUi(_ windowsToUpdate: [Window]? = nil) { + func refreshOpenUi(_ windowsToScreenshot: [Window], skipUpdatesBeforeShowing: Bool = false, onlyUpdateScreenshots: Bool = false) { + if !Appearance.hideThumbnails && !windowsToScreenshot.isEmpty { + Windows.refreshThumbnails(windowsToScreenshot, false, onlyUpdateScreenshots) + return + } guard appIsBeingUsed else { return } let currentScreen = NSScreen.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.refresh() + if !skipUpdatesBeforeShowing { + if !Windows.updatesBeforeShowing(currentScreen) { hideUi(); return } + } guard appIsBeingUsed else { return } - refreshSpecificWindows(windowsToUpdate, currentScreen) - if (!Windows.list.contains { $0.shouldShowTheUser }) { hideUi(); return } - guard appIsBeingUsed else { return } - Windows.reorderList() Windows.updateFocusedWindowIndex() guard appIsBeingUsed else { return } thumbnailsPanel.thumbnailsView.updateItemsAndLayout(currentScreen) @@ -269,16 +269,12 @@ class App: AppCenterApplication, NSApplicationDelegate { thumbnailsPanel.display() guard appIsBeingUsed else { return } currentScreen.repositionPanel(thumbnailsPanel) + guard appIsBeingUsed else { return } Windows.voiceOverWindow() // at this point ThumbnailViews are assigned to the window, and ready - } - - private func refreshSpecificWindows(_ windowsToUpdate: [Window]?, _ currentScreen: NSScreen) -> ()? { - windowsToUpdate?.forEach { (window: Window) in - guard appIsBeingUsed else { return } - if !Appearance.hideThumbnails { window.refreshThumbnail() } - Windows.refreshIfWindowShouldBeShownToTheUser(window, currentScreen) - window.updatesWindowSpace() - } + guard appIsBeingUsed else { return } + Windows.previewFocusedWindowIfNeeded() + guard appIsBeingUsed else { return } + Applications.refreshBadgesAsync() } func showUiOrCycleSelection(_ shortcutIndex: Int) { @@ -286,27 +282,17 @@ class App: AppCenterApplication, NSApplicationDelegate { App.app.appIsBeingUsed = true if isFirstSummon || shortcutIndex != self.shortcutIndex { isFirstSummon = false - if Windows.list.count == 0 || MissionControl.state() == .showAllWindows || MissionControl.state() == .showFrontWindows { hideUi(); return } - // TODO: can the CGS call inside detectTabbedWindows introduce latency when WindowServer is busy? - Windows.detectTabbedWindows() - // TODO: find a way to update space info when spaces are changed, instead of on every trigger - // workaround: when Preferences > Mission Control > "Displays have separate Spaces" is unchecked, - // switching between displays doesn't trigger .activeSpaceDidChangeNotification; we get the latest manually - Spaces.refresh() - Windows.list.forEachAsync { $0.updatesWindowSpace() } - let screen = NSScreen.preferred() self.shortcutIndex = shortcutIndex - Windows.refreshWhichWindowsToShowTheUser(screen) - Windows.reorderList() - if (!Windows.list.contains { $0.shouldShowTheUser }) { hideUi(); return } + let screen = NSScreen.preferred() + if !Windows.updatesBeforeShowing(screen) { hideUi(); return } Windows.setInitialFocusedAndHoveredWindowIndex() if Preferences.windowDisplayDelay == DispatchTimeInterval.milliseconds(0) { - self.rebuildUi(screen) + buildUiAndShowPanel(screen) } else { delayedDisplayScheduled += 1 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Preferences.windowDisplayDelay) { () -> () in if self.delayedDisplayScheduled == 1 { - self.rebuildUi(screen) + self.buildUiAndShowPanel(screen) } self.delayedDisplayScheduled -= 1 } @@ -317,21 +303,17 @@ class App: AppCenterApplication, NSApplicationDelegate { } } - func rebuildUi(_ screen: NSScreen = NSScreen.preferred()) { + func buildUiAndShowPanel(_ screen: NSScreen = NSScreen.preferred()) { guard appIsBeingUsed else { return } Appearance.update() guard appIsBeingUsed else { return } thumbnailsPanel.makeKeyAndOrderFront(nil) // workaround: without this, switching between 2 screens make thumbnailPanel invisible + KeyRepeatTimer.toggleRepeatingKeyNextWindow() guard appIsBeingUsed else { return } - refreshOpenUi() + refreshOpenUi([], skipUpdatesBeforeShowing: true) // first display with the data we have guard appIsBeingUsed else { return } thumbnailsPanel.show() - Windows.previewFocusedWindowIfNeeded() - guard appIsBeingUsed else { return } - Windows.refreshThumbnailsAsync(screen, 0) - guard appIsBeingUsed else { return } - Applications.refreshBadges() - KeyRepeatTimer.toggleRepeatingKeyNextWindow() + refreshOpenUi(Windows.list, skipUpdatesBeforeShowing: true, onlyUpdateScreenshots: true) // second refresh with fresh thumbnails } func checkIfShortcutsShouldBeDisabled(_ activeWindow: Window?, _ activeApp: NSRunningApplication?) { diff --git a/src/ui/generic-components/TableGroupView.swift b/src/ui/generic-components/TableGroupView.swift index c2ed3ad7..31eb4e21 100644 --- a/src/ui/generic-components/TableGroupView.swift +++ b/src/ui/generic-components/TableGroupView.swift @@ -314,10 +314,10 @@ class TableGroupView: ClickHoverStackView { tableStackView.orientation = .vertical tableStackView.spacing = 0 tableStackView.wantsLayer = true - tableStackView.layer?.backgroundColor = NSColor.tableBackgroundColor.cgColor - tableStackView.layer?.cornerRadius = TableGroupView.cornerRadius - tableStackView.layer?.borderColor = NSColor.tableBorderColor.cgColor - tableStackView.layer?.borderWidth = TableGroupView.borderWidth + tableStackView.layer!.backgroundColor = NSColor.tableBackgroundColor.cgColor + tableStackView.layer!.cornerRadius = TableGroupView.cornerRadius + tableStackView.layer!.borderColor = NSColor.tableBorderColor.cgColor + tableStackView.layer!.borderWidth = TableGroupView.borderWidth addArrangedSubview(tableStackView) tableStackViews.append(tableStackView) rowInfoTables.append([RowInfo]()) diff --git a/src/ui/main-window/ThumbnailFontIconView.swift b/src/ui/main-window/ThumbnailFontIconView.swift index 070d7b9d..729ae102 100644 --- a/src/ui/main-window/ThumbnailFontIconView.swift +++ b/src/ui/main-window/ThumbnailFontIconView.swift @@ -17,22 +17,43 @@ enum Symbols: String { // Font icon using SF Symbols from the SF Pro font from Apple // see https://developer.apple.com/design/human-interface-guidelines/sf-symbols/overview/ class ThumbnailFontIconView: ThumbnailTitleView { + static var paragraphStyle = { + let paragraphStyle = NSMutableParagraphStyle() + // clip the top of the box since we know these symbols are always disks + paragraphStyle.lineHeightMultiple = 0.85 + return paragraphStyle + }() + var initialAttributedString: NSMutableAttributedString! convenience init(symbol: Symbols, tooltip: String? = nil, size: CGFloat = Appearance.fontHeight, color: NSColor = Appearance.fontColor, shadow: NSShadow? = ThumbnailView.makeShadow(Appearance.indicatedIconShadowColor)) { // This helps SF symbols display vertically centered and not clipped at the top self.init(shadow: shadow, font: NSFont(name: "SF Pro Text", size: (size * 0.85).rounded())!) - stringValue = symbol.rawValue + initialAttributedString = NSMutableAttributedString(string: symbol.rawValue, attributes: [.paragraphStyle: ThumbnailFontIconView.paragraphStyle]) + attributedStringValue = initialAttributedString textColor = color - addOrUpdateConstraint(widthAnchor, cell!.cellSize.width) toolTip = tooltip - fixHeight() + addOrUpdateConstraint(widthAnchor, cell!.cellSize.width) } // number should be in the interval [0-50] func setNumber(_ number: Int, _ filled: Bool) { let (baseCharacter, offset) = baseCharacterAndOffset(number, filled) - assignIfDifferent(&stringValue, String(UnicodeScalar(Int(baseCharacter.unicodeScalars.first!.value) + offset)!)) + replaceCharIfNeeded(String(UnicodeScalar(Int(baseCharacter.unicodeScalars.first!.value) + offset)!)) + } + func setStar() { + replaceCharIfNeeded(Symbols.circledStar.rawValue) + } + + func setFilledStar() { + replaceCharIfNeeded(Symbols.filledCircledStar.rawValue) + } + + private func replaceCharIfNeeded(_ newChar: String) { + if newChar != attributedStringValue.string { + initialAttributedString.replaceCharacters(in: NSRange(location: 0, length: 1), with: newChar) + attributedStringValue = initialAttributedString + } } private func baseCharacterAndOffset(_ number: Int, _ filled: Bool) -> (String, Int) { @@ -43,14 +64,6 @@ class ThumbnailFontIconView: ThumbnailTitleView { return ((filled ? Symbols.filledCircledNumber10 : Symbols.circledNumber10).rawValue, number - 10) } } - - func setStar() { - assignIfDifferent(&stringValue, Symbols.circledStar.rawValue) - } - - func setFilledStar() { - assignIfDifferent(&stringValue, Symbols.filledCircledStar.rawValue) - } } class ThumbnailFilledFontIconView: NSView { @@ -60,7 +73,10 @@ class ThumbnailFilledFontIconView: NSView { let backgroundView = ThumbnailFontIconView(symbol: .filledCircled, size: size - 4, color: backgroundColor, shadow: nil) addSubview(backgroundView) addSubview(thumbnailFontIconView, positioned: .above, relativeTo: nil) - backgroundView.frame.origin = CGPoint(x: backgroundView.frame.origin.x + 2, y: backgroundView.frame.origin.y + 2) - fit(thumbnailFontIconView.fittingSize.width, thumbnailFontIconView.fittingSize.height) + backgroundView.centerXAnchor.constraint(equalTo: thumbnailFontIconView.centerXAnchor).isActive = true + let offset = ((thumbnailFontIconView.cell!.cellSize.width - backgroundView.cell!.cellSize.width) / 2).rounded() + backgroundView.topAnchor.constraint(equalTo: thumbnailFontIconView.topAnchor, constant: offset).isActive = true + widthAnchor.constraint(equalTo: thumbnailFontIconView.widthAnchor).isActive = true + heightAnchor.constraint(equalTo: thumbnailFontIconView.heightAnchor).isActive = true } } diff --git a/src/ui/main-window/ThumbnailTitleView.swift b/src/ui/main-window/ThumbnailTitleView.swift index d7e2fca6..d05055c3 100644 --- a/src/ui/main-window/ThumbnailTitleView.swift +++ b/src/ui/main-window/ThumbnailTitleView.swift @@ -6,7 +6,6 @@ class ThumbnailTitleView: NSTextField { self.font = font textColor = Appearance.fontColor self.shadow = shadow - lineBreakMode = getTruncationMode() allowsDefaultTighteningForTruncation = false translatesAutoresizingMaskIntoConstraints = false } @@ -19,6 +18,13 @@ class ThumbnailTitleView: NSTextField { // no-op here prevents tooltips from disappearing on mouseMoved } + func updateTruncationModeIfNeeded() { + let newLineBreakMode = getTruncationMode() + if lineBreakMode != newLineBreakMode { + lineBreakMode = newLineBreakMode + } + } + private func getTruncationMode() -> NSLineBreakMode { if Preferences.titleTruncation == .end { return .byTruncatingTail diff --git a/src/ui/main-window/ThumbnailView.swift b/src/ui/main-window/ThumbnailView.swift index d9ec3631..7cdd8520 100644 --- a/src/ui/main-window/ThumbnailView.swift +++ b/src/ui/main-window/ThumbnailView.swift @@ -1,27 +1,28 @@ import Cocoa -class ThumbnailView: NSStackView { +class ThumbnailView: FlippedView { static let noOpenWindowToolTip = NSLocalizedString("App is running but has no open window", comment: "") var window_: Window? var thumbnail = NSImageView() var windowlessIcon = NSImageView() - var thumbnailContainer: NSStackView! + let thumbnailContainer = FlippedView() var appIcon = NSImageView() var label = ThumbnailTitleView(shadow: ThumbnailView.makeShadow(Appearance.titleShadowColor), font: Appearance.font) var fullscreenIcon = ThumbnailFontIconView(symbol: .circledPlusSign, tooltip: NSLocalizedString("Window is fullscreen", comment: "")) var minimizedIcon = ThumbnailFontIconView(symbol: .circledMinusSign, tooltip: NSLocalizedString("Window is minimized", comment: "")) var hiddenIcon = ThumbnailFontIconView(symbol: .circledSlashSign, tooltip: NSLocalizedString("App is hidden", comment: "")) var spaceIcon = ThumbnailFontIconView(symbol: .circledNumber0) - var dockLabelIcon = ThumbnailFilledFontIconView(ThumbnailFontIconView(symbol: .filledCircledNumber0, size: dockLabelLabelSize(), - color: NSColor(srgbRed: 1, green: 0.30, blue: 0.25, alpha: 1), shadow: nil), backgroundColor: NSColor.white, size: dockLabelLabelSize()) + var dockLabelIcon = ThumbnailFilledFontIconView( + ThumbnailFontIconView(symbol: .filledCircledNumber0, size: dockLabelLabelSize(), color: NSColor(srgbRed: 1, green: 0.30, blue: 0.25, alpha: 1), shadow: nil), + backgroundColor: NSColor.white, size: dockLabelLabelSize()) var quitIcon = TrafficLightButton(.quit, NSLocalizedString("Quit app", comment: "")) var closeIcon = TrafficLightButton(.close, NSLocalizedString("Close window", comment: "")) var minimizeIcon = TrafficLightButton(.miniaturize, NSLocalizedString("Minimize/Deminimize window", comment: "")) var maximizeIcon = TrafficLightButton(.fullscreen, NSLocalizedString("Fullscreen/Defullscreen window", comment: "")) var windowlessAppIndicator = WindowlessAppIndicator(tooltip: ThumbnailView.noOpenWindowToolTip) - var hStackView: NSStackView! - var vStackView: NSStackView! + let hStackView = FlippedView() + let vStackView = FlippedView() var mouseUpCallback: (() -> Void)! var mouseMovedCallback: (() -> Void)! var dragAndDropTimer: Timer? @@ -33,7 +34,8 @@ class ThumbnailView: NSStackView { var indexInRow = 0 var numberOfViewsInRow = 0 - var windowControlIcons: [TrafficLightButton]! + var windowControlIcons: [TrafficLightButton] { [quitIcon, closeIcon, minimizeIcon, maximizeIcon] } + var windowIndicatorIcons: [ThumbnailFontIconView] { [hiddenIcon, fullscreenIcon, minimizedIcon, spaceIcon] } // for VoiceOver cursor override var canBecomeKeyView: Bool { true } @@ -41,149 +43,64 @@ class ThumbnailView: NSStackView { override func isAccessibilityElement() -> Bool { true } + override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { + mouseMovedCallback() + return .link + } + + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { + dragAndDropTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in + self.mouseUpCallback() + }) + dragAndDropTimer?.tolerance = 0.2 + return .link + } + + override func draggingExited(_ sender: NSDraggingInfo?) { + dragAndDropTimer?.invalidate() + dragAndDropTimer = nil + } + + override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + dragAndDropTimer?.invalidate() + dragAndDropTimer = nil + let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as! [URL] + let appUrl = window_!.application.runningApplication.bundleURL! + let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:]) + App.app.hideUi() + return open != nil + } + + override func mouseUp(with event: NSEvent) { + if event.clickCount >= 1 { + mouseUpCallback() + } + } + + override func otherMouseUp(with event: NSEvent) { + // middle-click + if event.buttonNumber == 2 { + window_?.close() + } + } + + func mouseMoved() { + showOrHideWindowControls(true) + mouseMovedCallback() + } + convenience init() { self.init(frame: .zero) setupView() observeDragAndDrop() } - private func setupView() { - orientation = .vertical - let shadow = ThumbnailView.makeShadow(Appearance.imageShadowColor) - thumbnail.shadow = shadow - windowlessIcon.toolTip = ThumbnailView.noOpenWindowToolTip - windowlessIcon.shadow = shadow - appIcon.shadow = shadow - label.fixHeight() - windowControlIcons = [quitIcon, closeIcon, minimizeIcon, maximizeIcon] - addViews() - addDockLabelIcon() - addWindowlessIndicator() - setAccessibilityChildren([]) - } - - private func addViews() { - vStackView = NSStackView() - vStackView.translatesAutoresizingMaskIntoConstraints = false - vStackView.orientation = .vertical - vStackView.wantsLayer = true - vStackView.layer!.backgroundColor = .clear - vStackView.layer!.borderColor = .clear - vStackView.layer!.cornerRadius = Appearance.cellCornerRadius - vStackView.layer!.borderWidth = CGFloat(1) - vStackView.edgeInsets = NSEdgeInsets(top: Appearance.edgeInsetsSize, left: Appearance.edgeInsetsSize, - bottom: Appearance.edgeInsetsSize, right: Appearance.edgeInsetsSize) - setViews([vStackView], in: .leading) - if Preferences.appearanceStyle == .appIcons { - // The label is outside and below the selected icon in AppIcons style - hStackView = NSStackView(views: [appIcon]) - hStackView.translatesAutoresizingMaskIntoConstraints = false - vStackView.setViews([hStackView], in: .leading) - label.alignment = .center - label.isHidden = true - addSubview(label) - NSLayoutConstraint.activate([ - hStackView.heightAnchor.constraint(equalTo: appIcon.heightAnchor), - vStackView.heightAnchor.constraint(equalTo: hStackView.heightAnchor, constant: Appearance.intraCellPadding), - label.topAnchor.constraint(equalTo: vStackView.bottomAnchor, constant: 8), // subjectively looks vertically centered - ]) - } else { - hStackView = NSStackView(views: [appIcon, label, hiddenIcon, fullscreenIcon, minimizedIcon, spaceIcon]) - hStackView.translatesAutoresizingMaskIntoConstraints = false - hStackView.distribution = .fillProportionally - thumbnailContainer = NSStackView(views: [thumbnail, windowlessIcon]) - thumbnailContainer.orientation = .vertical - vStackView.setViews([hStackView, thumbnailContainer], in: .leading) - NSLayoutConstraint.activate([ - vStackView.bottomAnchor.constraint(equalTo: bottomAnchor), - label.centerYAnchor.constraint(equalTo: hStackView.centerYAnchor), - thumbnailContainer.leftAnchor.constraint(equalTo: leftAnchor), - thumbnailContainer.rightAnchor.constraint(equalTo: rightAnchor), - ]) - if Preferences.appearanceStyle == .thumbnails { - windowControlIcons.forEach { - thumbnailContainer.addSubview($0, positioned: .above, relativeTo: nil) - $0.isHidden = true - } - } - } - NSLayoutConstraint.activate([ - vStackView.leadingAnchor.constraint(equalTo: leadingAnchor), - vStackView.trailingAnchor.constraint(equalTo: trailingAnchor), - vStackView.topAnchor.constraint(equalTo: topAnchor), - ]) - } - - private func addDockLabelIcon() { - appIcon.addSubview(dockLabelIcon, positioned: .above, relativeTo: nil) - } - - private func addWindowlessIndicator() { - addSubview(windowlessAppIndicator, positioned: .above, relativeTo: nil) - } - - func showOrHideWindowControls(_ shouldShowWindowControls: Bool) { - let shouldShow = shouldShowWindowControls && !Preferences.hideColoredCircles && !Appearance.hideThumbnails - if Preferences.appearanceStyle == .thumbnails, isShowingWindowControls != shouldShow { - isShowingWindowControls = shouldShow - var xOffset = 3 + Appearance.edgeInsetsSize - var yOffset = 2 + TrafficLightButton.size - windowControlIcons.forEach { icon in - icon.isHidden = !shouldShow || - (icon.type == .quit && !(window_?.application.canBeQuit() ?? true)) || - (icon.type == .close && !(window_?.canBeClosed() ?? true)) || - ((icon.type == .miniaturize || icon.type == .fullscreen) && !(window_?.canBeMinDeminOrFullscreened() ?? true)) - if !icon.isHidden { - icon.setFrameOrigin(NSPoint( - x: xOffset, - y: thumbnailContainer.frame.height - yOffset)) - xOffset += TrafficLightButton.size + TrafficLightButton.spacing - if xOffset + TrafficLightButton.size > thumbnailContainer.frame.width { - xOffset = 3 + Appearance.edgeInsetsSize - yOffset += TrafficLightButton.size + TrafficLightButton.spacing - } - } - // Force the icons to redraw, or after clicking the fullscreen button, - // it will still appear to be in fullscreen mode. - icon.display() - } - } - } - - private func getBackgroundColor(isFocused: Bool, isHovered: Bool) -> NSColor { - if isFocused { - return Appearance.highlightFocusedBackgroundColor - } - if isHovered { - return Appearance.highlightHoveredBackgroundColor - } - return NSColor.clear - } - - private func setBackground(isFocused: Bool, isHovered: Bool) { - vStackView?.layer!.backgroundColor = getBackgroundColor(isFocused: isFocused, isHovered: isHovered).cgColor - } - - private func setBorder(isFocused: Bool, isHovered: Bool) { - if isFocused { - vStackView?.layer!.borderColor = Appearance.highlightFocusedBorderColor.cgColor - vStackView?.layer!.borderWidth = Appearance.highlightBorderWidth - } else if isHovered { - vStackView?.layer!.borderColor = Appearance.highlightHoveredBorderColor.cgColor - vStackView?.layer!.borderWidth = Appearance.highlightBorderWidth - } else { - vStackView?.layer!.borderColor = NSColor.clear.cgColor - vStackView?.layer!.borderWidth = 0 - } - } - - private func setShadow(isFocused: Bool, isHovered: Bool) { - if (isFocused || isHovered) && Appearance.highlightBorderShadowColor != .clear { - vStackView?.layer!.shadowColor = Appearance.highlightBorderShadowColor.cgColor - vStackView?.layer!.shadowOpacity = 0.25 - vStackView?.layer!.shadowOffset = .zero - vStackView?.layer!.shadowRadius = 1 - } + func updateRecycledCellWithNewContent(_ element: Window, _ index: Int, _ newHeight: CGFloat, _ screen: NSScreen) { + window_ = element + updateValues(element, index, newHeight, screen) + updateSizes(newHeight, screen) + updatePositions(newHeight, screen) + label.toolTip = label.cell!.cellSize.width >= label.frame.size.width ? label.stringValue : nil } func drawHighlight() { @@ -198,13 +115,131 @@ class ThumbnailView: NSStackView { } } + func showOrHideWindowControls(_ shouldShowWindowControls: Bool) { + let shouldShow = shouldShowWindowControls && !Preferences.hideColoredCircles && !Appearance.hideThumbnails + if Preferences.appearanceStyle == .thumbnails, isShowingWindowControls != shouldShow { + isShowingWindowControls = shouldShow + for icon in windowControlIcons { + icon.isHidden = !shouldShow + || (icon.type == .quit && !(window_?.application.canBeQuit() ?? true)) + || (icon.type == .close && !(window_?.canBeClosed() ?? true)) + || ((icon.type == .miniaturize || icon.type == .fullscreen) && !(window_?.canBeMinDeminOrFullscreened() ?? true)) + // Force the icons to redraw; otherwise after clicking the fullscreen button, + // it will still appear to be in fullscreen mode. + icon.display() + } + } + } + + @discardableResult + func updateDockLabelIcon(_ dockLabel: String?) -> Bool { + assignIfDifferent(&dockLabelIcon.isHidden, dockLabel == nil || Preferences.hideAppBadges || Appearance.iconSize == 0) + if !dockLabelIcon.isHidden, let dockLabel = dockLabel { + let view = dockLabelIcon.subviews[1] as! ThumbnailFontIconView + let dockLabelInt = Int(dockLabel) + if dockLabelInt == nil || dockLabelInt! > 30 { + view.setFilledStar() + } else { + view.setNumber(dockLabelInt!, true) + } + view.setAccessibilityLabel(getAccessibilityTextForBadge(dockLabel)) + return true + } + return false + } + + private func setupView() { + translatesAutoresizingMaskIntoConstraints = false + setAccessibilityChildren([]) + wantsLayer = true + layer!.masksToBounds = false // without this, label will be clipped in app-icons style since its larger than its parentView + setupSharedSubiews() + setupStyleSpecificSubviews() + } + + private func setupSharedSubiews() { + let shadow = ThumbnailView.makeShadow(Appearance.imageShadowColor) + thumbnail.shadow = shadow + windowlessIcon.translatesAutoresizingMaskIntoConstraints = false + windowlessIcon.toolTip = ThumbnailView.noOpenWindowToolTip + windowlessIcon.shadow = shadow + appIcon.translatesAutoresizingMaskIntoConstraints = false + appIcon.shadow = shadow + appIcon.setSubviewAbove(dockLabelIcon) + label.fixHeight() + vStackView.wantsLayer = true + vStackView.layer!.backgroundColor = .clear + vStackView.layer!.borderColor = .clear + vStackView.layer!.cornerRadius = Appearance.cellCornerRadius + vStackView.layer!.borderWidth = CGFloat(1) + setSubviews([vStackView]) + vStackView.setSubviews([hStackView]) + hStackView.setSubviews([appIcon]) + } + + private func setupStyleSpecificSubviews() { + if Preferences.appearanceStyle == .appIcons { + addSubviews([label]) + hStackView.setSubviewAbove(windowlessAppIndicator) + label.alignment = .center + label.isHidden = true + } else if Preferences.appearanceStyle == .thumbnails { + vStackView.addSubviews([thumbnailContainer]) + thumbnailContainer.setSubviews([thumbnail, windowlessIcon]) + thumbnailContainer.setSubviewAbove(windowlessAppIndicator) + for icon in windowControlIcons { + thumbnailContainer.setSubviewAbove(icon) + icon.isHidden = true + } + hStackView.addSubviews([label] + windowIndicatorIcons) + } else { + hStackView.setSubviewAbove(windowlessAppIndicator) + hStackView.addSubviews([label] + windowIndicatorIcons) + } + } + + private func getBackgroundColor(isFocused: Bool, isHovered: Bool) -> NSColor { + if isFocused { + return Appearance.highlightFocusedBackgroundColor + } + if isHovered { + return Appearance.highlightHoveredBackgroundColor + } + return NSColor.clear + } + + private func setBackground(isFocused: Bool, isHovered: Bool) { + vStackView.layer!.backgroundColor = getBackgroundColor(isFocused: isFocused, isHovered: isHovered).cgColor + } + + private func setBorder(isFocused: Bool, isHovered: Bool) { + if isFocused { + vStackView.layer!.borderColor = Appearance.highlightFocusedBorderColor.cgColor + vStackView.layer!.borderWidth = Appearance.highlightBorderWidth + } else if isHovered { + vStackView.layer!.borderColor = Appearance.highlightHoveredBorderColor.cgColor + vStackView.layer!.borderWidth = Appearance.highlightBorderWidth + } else { + vStackView.layer!.borderColor = NSColor.clear.cgColor + vStackView.layer!.borderWidth = 0 + } + } + + private func setShadow(isFocused: Bool, isHovered: Bool) { + if (isFocused || isHovered) && Appearance.highlightBorderShadowColor != .clear { + vStackView.layer!.shadowColor = Appearance.highlightBorderShadowColor.cgColor + vStackView.layer!.shadowOpacity = 0.25 + vStackView.layer!.shadowOffset = .zero + vStackView.layer!.shadowRadius = 1 + } + } + private func updateAppIconsLabel(isFocused: Bool, isHovered: Bool) { let focusedView = ThumbnailsView.recycledViews[Windows.focusedWindowIndex] var hoveredView: ThumbnailView? = nil if Windows.hoveredWindowIndex != nil { hoveredView = ThumbnailsView.recycledViews[Windows.hoveredWindowIndex!] } - if isFocused || (!isFocused && !isHovered) { hoveredView?.label.isHidden = true focusedView.label.isHidden = false @@ -218,7 +253,7 @@ class ThumbnailView: NSStackView { } } - func getMaxAllowedLabelWidth(_ view: ThumbnailView) -> CGFloat { + private func getMaxAllowedLabelWidth(_ view: ThumbnailView) -> CGFloat { let viewWidth = view.frame.width let maxAllowedWidth = min(viewWidth * 2, ThumbnailsView.thumbnailsWidth) @@ -256,50 +291,17 @@ class ThumbnailView: NSStackView { } } let xPosition = -leftOffset - let yPosition = Appearance.intraCellPadding let height = view.label.cell!.cellSize.height + let yPosition = view.hStackView.frame.origin.y + view.hStackView.frame.height + Appearance.intraCellPadding * 2 view.label.frame = NSRect(x: xPosition, y: yPosition, width: effectiveLabelWidth, height: height) view.label.addOrUpdateConstraint(view.label.widthAnchor, effectiveLabelWidth) view.label.toolTip = view.label.cell!.cellSize.width >= view.label.frame.size.width ? view.label.stringValue : nil } - func updateRecycledCellWithNewContent(_ element: Window, _ index: Int, _ newHeight: CGFloat, _ screen: NSScreen) { - window_ = element + private func updateValues(_ element: Window, _ index: Int, _ newHeight: CGFloat, _ screen: NSScreen) { assignIfDifferent(&thumbnail.isHidden, Appearance.hideThumbnails || element.isWindowlessApp) - if !thumbnail.isHidden { - thumbnail.image = element.thumbnail - if let image = thumbnail.image { - image.size = element.thumbnailFullSize! - } else { - thumbnail.image = element.icon?.copy() as? NSImage - thumbnail.image?.size = NSSize(width: 1024, height: 1024) - } - let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail.image, screen, false) - thumbnail.image?.size = thumbnailSize - thumbnail.frame.size = thumbnailSize - // for Accessibility > "speak items under the pointer" - thumbnail.setAccessibilityLabel(element.title) - } - assignIfDifferent(&vStackView.spacing, Appearance.hideThumbnails ? 0 : Appearance.intraCellPadding) - assignIfDifferent(&spacing, Appearance.intraCellPadding) - assignIfDifferent(&hStackView.spacing, Appearance.fontHeight == 0 ? 0 : Appearance.intraCellPadding) - let title = getAppOrAndWindowTitle() - let appIconChanged = appIcon.image != element.icon - if appIconChanged { - appIcon.image = element.icon - let appIconSize = ThumbnailView.iconSize(screen) - appIcon.image?.size = appIconSize - appIcon.addOrUpdateConstraint(appIcon.widthAnchor, appIconSize.width) - appIcon.addOrUpdateConstraint(appIcon.heightAnchor, appIconSize.height) - appIcon.setAccessibilityLabel(title) - } - let labelChanged = label.stringValue != title - if labelChanged { - label.stringValue = title - // workaround: setting string on NSTextView changes the font (most likely a Cocoa bug) - label.font = Appearance.font - setAccessibilityLabel(title) - } + assignIfDifferent(&windowlessIcon.isHidden, !element.isWindowlessApp || Appearance.hideThumbnails) + assignIfDifferent(&windowlessAppIndicator.isHidden, !element.isWindowlessApp) assignIfDifferent(&hiddenIcon.isHidden, !element.isHidden || Preferences.hideStatusIcons) assignIfDifferent(&fullscreenIcon.isHidden, !element.isFullscreen || Preferences.hideStatusIcons) assignIfDifferent(&minimizedIcon.isHidden, !element.isMinimized || Preferences.hideStatusIcons) @@ -308,6 +310,18 @@ class ThumbnailView: NSStackView { NSScreen.screens.count < 2 || Preferences.screensToShow[App.app.shortcutIndex] == .showingAltTab ) )) + if !thumbnail.isHidden { + thumbnail.image = element.thumbnail + // for Accessibility > "speak items under the pointer" + thumbnail.setAccessibilityLabel(element.title) + } + let title = getAppOrAndWindowTitle() + let labelChanged = label.stringValue != title + if labelChanged { + label.stringValue = title + setAccessibilityLabel(title) + } + label.updateTruncationModeIfNeeded() if !spaceIcon.isHidden { if element.spaceIndex > 30 || element.isOnAllSpaces { spaceIcon.setStar() @@ -317,39 +331,120 @@ class ThumbnailView: NSStackView { spaceIcon.toolTip = String(format: NSLocalizedString("Window is on Space %d", comment: ""), element.spaceIndex) } } + let appIconChanged = appIcon.image != element.icon + if appIconChanged { + appIcon.image = element.icon + appIcon.setAccessibilityLabel(title) + } let dockLabelChanged = updateDockLabelIcon(element.dockLabel) if appIconChanged || dockLabelChanged { setAccessibilityHelp(getAccessibilityHelp(element.application.runningApplication.localizedName, element.dockLabel)) } - label.toolTip = label.cell!.cellSize.width >= label.frame.size.width ? label.stringValue : nil - assignIfDifferent(&windowlessIcon.isHidden, !element.isWindowlessApp || Appearance.hideThumbnails) if element.isWindowlessApp { windowlessIcon.image = appIcon.image!.copy() as? NSImage - windowlessIcon.image?.size = NSSize(width: 1024, height: 1024) - let windowlessIconSize = ThumbnailView.thumbnailSize(windowlessIcon.image, screen, true) - windowlessIcon.image!.size = windowlessIconSize - windowlessIcon.frame.size = windowlessIconSize - windowlessIcon.needsDisplay = true } - setFrameWidthHeight(element, screen, newHeight) - let hStackViewWidth = frame.width - Appearance.edgeInsetsSize * 2 - hStackView.addOrUpdateConstraint(hStackView.widthAnchor, hStackViewWidth) - updateWindowlessIndicator(element, screen) - self.mouseUpCallback = { () -> Void in App.app.focusSelectedWindow(element) } - self.mouseMovedCallback = { () -> Void in Windows.updateFocusedAndHoveredWindowIndex(index, true) } windowControlIcons.forEach { $0.window_ = element } showOrHideWindowControls(false) - // force a display to avoid flickering; see https://github.com/lwouis/alt-tab-macos/issues/197 - // quirk: display() should be called last as it resets thumbnail.frame.size somehow - if labelChanged { - label.display() + mouseUpCallback = { () -> Void in App.app.focusSelectedWindow(element) } + mouseMovedCallback = { () -> Void in Windows.updateFocusedAndHoveredWindowIndex(index, true) } + } + + private func updateSizes(_ newHeight: CGFloat, _ screen: NSScreen) { + if Preferences.appearanceStyle == .thumbnails { + if !thumbnail.isHidden { + let thumbnailSize = ThumbnailView.thumbnailSize(thumbnail.image, screen, false) + thumbnail.image?.size = thumbnailSize + thumbnail.frame.size = thumbnailSize + } + if !windowlessIcon.isHidden { + let windowlessIconSize = ThumbnailView.thumbnailSize(windowlessIcon.image, screen, true) + windowlessIcon.image!.size = windowlessIconSize + windowlessIcon.frame.size = windowlessIconSize + } + } + setFrameWidthHeight(screen, newHeight) + let appIconSize = ThumbnailView.iconSize(screen) + appIcon.image?.size = appIconSize + appIcon.frame.size = appIconSize + if Preferences.appearanceStyle == .appIcons { + vStackView.frame.size = NSSize(width: frame.width, height: appIcon.frame.height + Appearance.edgeInsetsSize * 2) + hStackView.frame.size = NSSize(width: appIcon.frame.width, height: appIcon.frame.height) + } else { + vStackView.frame.size = NSSize(width: frame.width, height: frame.height) + hStackView.frame.size = NSSize(width: frame.width - Appearance.edgeInsetsSize * 2, height: max(appIcon.frame.height, label.cell!.cellSize.height)) + let labelWidth = hStackView.frame.width - appIcon.frame.width - Appearance.intraCellPadding - indicatorsSpace() + label.frame.size.width = labelWidth + label.addOrUpdateConstraint(label.widthAnchor, labelWidth) + } + if Preferences.appearanceStyle == .thumbnails { + let content = !windowlessIcon.isHidden ? windowlessIcon : thumbnail + thumbnailContainer.frame.size = NSSize(width: hStackView.frame.width, height: content.frame.height) } } - func getAppOrAndWindowTitle() -> String { + private func updatePositions(_ newHeight: CGFloat, _ screen: NSScreen) { + hStackView.frame.origin = NSPoint(x: Appearance.edgeInsetsSize, y: Appearance.edgeInsetsSize) + if Preferences.appearanceStyle != .appIcons { + appIcon.frame.origin.x = App.shared.userInterfaceLayoutDirection == .leftToRight + ? 0 + : hStackView.frame.width - appIcon.frame.width + let iconWidth = windowIndicatorIcons.first!.cell!.cellSize.width + var indicatorSpace = CGFloat(0) + for icon in windowIndicatorIcons { + if !icon.isHidden { + indicatorSpace += iconWidth + icon.centerFrameInParent(y: true) + icon.frame.origin.x = App.shared.userInterfaceLayoutDirection == .leftToRight + ? hStackView.frame.width - indicatorSpace + : indicatorSpace - iconWidth + } + } + let labelWidth = hStackView.frame.width - appIcon.frame.width - Appearance.intraCellPadding - indicatorSpace + label.frame.origin.x = App.shared.userInterfaceLayoutDirection == .leftToRight + ? appIcon.frame.maxX + Appearance.intraCellPadding + : hStackView.frame.width - appIcon.frame.width - Appearance.intraCellPadding - labelWidth + label.centerFrameInParent(y: true) + } + if Preferences.appearanceStyle == .thumbnails { + let content = !windowlessIcon.isHidden ? windowlessIcon : thumbnail + thumbnailContainer.frame.origin = NSPoint(x: Appearance.edgeInsetsSize, y: hStackView.frame.maxY + Appearance.intraCellPadding) + content.centerFrameInParent(x: true) + var xOffset = CGFloat(3) + var yOffset = CGFloat(2) + for icon in windowControlIcons { + icon.frame.origin = NSPoint(x: xOffset, y: yOffset) + xOffset += TrafficLightButton.size + TrafficLightButton.spacing + if xOffset + TrafficLightButton.size > thumbnailContainer.frame.width { + xOffset = 3 + Appearance.edgeInsetsSize + yOffset += TrafficLightButton.size + TrafficLightButton.spacing + } + } + } + if !windowlessAppIndicator.isHidden { + if Preferences.appearanceStyle != .titles { + windowlessAppIndicator.centerFrameInParent(x: true) + } else { + windowlessAppIndicator.frame.origin.x = ((appIcon.frame.width / 2) - (windowlessAppIndicator.frame.width / 2)).rounded() + + (App.shared.userInterfaceLayoutDirection == .leftToRight ? 0 : appIcon.frame.origin.x) + } + windowlessAppIndicator.frame.origin.y = windowlessAppIndicator.superview!.frame.height - windowlessAppIndicator.frame.height + } + if !dockLabelIcon.isHidden { + let iconSize = ThumbnailView.iconSize(screen) + dockLabelIcon.frame.origin.x = ((iconSize.width * 0.8) - (dockLabelIcon.fittingSize.width / 2)).rounded() + dockLabelIcon.frame.origin.y = ((iconSize.height * 0.8) - (dockLabelIcon.fittingSize.height / 2)).rounded() + - (Preferences.appearanceSize == .small ? 2 : 0) + } + } + + private func indicatorsSpace() -> CGFloat { + let iconWidth = windowIndicatorIcons.first!.cell!.cellSize.width + return CGFloat(windowIndicatorIcons.filter{ !$0.isHidden }.count) * iconWidth + } + + private func getAppOrAndWindowTitle() -> String { let appName = window_?.application.runningApplication.localizedName let windowTitle = window_?.title - if Preferences.onlyShowApplications() || Preferences.showTitles == .appName { return appName ?? "" } else if Preferences.showTitles == .appNameAndWindowTitle { @@ -358,84 +453,31 @@ class ThumbnailView: NSStackView { return windowTitle ?? "" } - func setFrameWidthHeight(_ element: Window, _ screen: NSScreen, _ newHeight: CGFloat) { - // Retrieves the minimum width for the screen. - let widthMin = ThumbnailView.minThumbnailWidth(screen) - let leftRightEdgeInsetsSize = ThumbnailView.getLeftRightEdgeInsetsSize() - var width = CGFloat(0) + private func setFrameWidthHeight(_ screen: NSScreen, _ newHeight: CGFloat) { var contentWidth = CGFloat(0) if Preferences.appearanceStyle == .thumbnails { // Preferred to the width of the image, and the minimum width may be set to be large. - if element.isWindowlessApp { - contentWidth = windowlessIcon.frame.size.width - } else { - contentWidth = thumbnail.frame.size.width - } + contentWidth = (!windowlessIcon.isHidden ? windowlessIcon : thumbnail).frame.size.width } else if Preferences.appearanceStyle == .titles { contentWidth = ThumbnailView.maxThumbnailWidth(screen) - Appearance.edgeInsetsSize * 2 } else { - contentWidth = max(hStackView.frame.size.width, Appearance.iconSize) + contentWidth = Appearance.iconSize } + let leftRightEdgeInsetsSize = ThumbnailView.getLeftRightEdgeInsetsSize() let frameWidth = (contentWidth + leftRightEdgeInsetsSize).rounded() - width = max(frameWidth, widthMin).rounded() + let widthMin = ThumbnailView.minThumbnailWidth(screen) + let width = max(frameWidth, widthMin).rounded() assignIfDifferent(&frame.size.width, width) assignIfDifferent(&frame.size.height, newHeight) } - func updateWindowlessIndicator(_ element: Window, _ screen: NSScreen) { - assignIfDifferent(&windowlessAppIndicator.isHidden, !element.isWindowlessApp) - if element.isWindowlessApp { - var xOffset = CGFloat(0) - var yOffset = CGFloat(0) - if Preferences.appearanceStyle == .thumbnails { - xOffset = (frame.size.width - windowlessAppIndicator.frame.size.width) / 2 - yOffset = Appearance.edgeInsetsSize - } else if Preferences.appearanceStyle == .appIcons { - xOffset = (frame.size.width - windowlessAppIndicator.frame.size.width) / 2 - yOffset = label.cell!.cellSize.height + 2 * Appearance.intraCellPadding + Appearance.edgeInsetsSize / 2 - } else if Preferences.appearanceStyle == .titles { - let iconSize = ThumbnailView.iconSize(screen) - yOffset = Appearance.edgeInsetsSize / 2 - if App.shared.userInterfaceLayoutDirection == .leftToRight { - xOffset = Appearance.edgeInsetsSize + (iconSize.width - windowlessAppIndicator.frame.size.width) / 2 - } else { - xOffset = frame.size.width - Appearance.edgeInsetsSize - (iconSize.width / 2) - (windowlessAppIndicator.frame.size.width / 2) - } - } - assignIfDifferent(&windowlessAppIndicator.frame.origin.x, xOffset) - assignIfDifferent(&windowlessAppIndicator.frame.origin.y, yOffset) - } - } - - @discardableResult - func updateDockLabelIcon(_ dockLabel: String?) -> Bool { - assignIfDifferent(&dockLabelIcon.isHidden, dockLabel == nil || Preferences.hideAppBadges || Appearance.iconSize == 0) - if !dockLabelIcon.isHidden, let dockLabel = dockLabel { - let view = dockLabelIcon.subviews[1] as! ThumbnailFontIconView - let dockLabelInt = Int(dockLabel) - if dockLabelInt == nil || dockLabelInt! > 30 { - view.setFilledStar() - } else { - view.setNumber(dockLabelInt!, true) - } - let badgeOffset = Preferences.appearanceSize == .small ? CGFloat(1) : CGFloat(0) - let iconSize = ThumbnailView.iconSize(NSScreen.preferred()) - dockLabelIcon.setFrameOrigin(NSPoint( - x: iconSize.width - (dockLabelIcon.fittingSize.width / 2) - (iconSize.width / 7) - badgeOffset, - y: iconSize.height - (dockLabelIcon.fittingSize.height / 2) - (iconSize.height / 5) - badgeOffset)) - view.setAccessibilityLabel(getAccessibilityTextForBadge(dockLabel)) - return true - } - return false - } - - func getAccessibilityHelp(_ appName: String?, _ dockLabel: String?) -> String { + private func getAccessibilityHelp(_ appName: String?, _ dockLabel: String?) -> String { [appName, dockLabel.map { getAccessibilityTextForBadge($0) }] .compactMap { $0 } .joined(separator: " - ") } - func getAccessibilityTextForBadge(_ dockLabel: String) -> String { + private func getAccessibilityTextForBadge(_ dockLabel: String) -> String { if let dockLabelInt = Int(dockLabel) { return "Red badge with number \(dockLabelInt)" } @@ -451,54 +493,10 @@ class ThumbnailView: NSStackView { registerForDraggedTypes([NSPasteboard.PasteboardType(kUTTypeURL as String)]) } - override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { - mouseMovedCallback() - return .link - } - - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - dragAndDropTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false, block: { _ in - self.mouseUpCallback() - }) - dragAndDropTimer?.tolerance = 0.2 - return .link - } - - override func draggingExited(_ sender: NSDraggingInfo?) { - dragAndDropTimer?.invalidate() - dragAndDropTimer = nil - } - - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - dragAndDropTimer?.invalidate() - dragAndDropTimer = nil - let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as! [URL] - let appUrl = window_!.application.runningApplication.bundleURL! - let open = try? NSWorkspace.shared.open(urls, withApplicationAt: appUrl, options: [], configuration: [:]) - App.app.hideUi() - return open != nil - } - - func mouseMoved() { - showOrHideWindowControls(true) - mouseMovedCallback() - } - - override func mouseUp(with event: NSEvent) { - if event.clickCount >= 1 { - mouseUpCallback() - } - } - - override func otherMouseUp(with event: NSEvent) { - // middle-click - if event.buttonNumber == 2 { - window_?.close() - } - } - static func dockLabelLabelSize() -> CGFloat { - return (ThumbnailView.iconSize(NSScreen.preferred()).width * 0.43).rounded() + let size = (ThumbnailView.iconSize(NSScreen.preferred()).width * 0.43).rounded() + // label should have a minimum size for readability + return max(size, 13) } static func makeShadow(_ color: NSColor?) -> NSShadow? { diff --git a/src/ui/preferences-window/LabelAndControl.swift b/src/ui/preferences-window/LabelAndControl.swift index 7fa01581..3c2a8629 100644 --- a/src/ui/preferences-window/LabelAndControl.swift +++ b/src/ui/preferences-window/LabelAndControl.swift @@ -37,6 +37,7 @@ class ClickHoverImageView: MouseHoverView { init(infoCircle: NSView) { super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false self.infoCircle = infoCircle addSubview(infoCircle) @@ -130,7 +131,7 @@ class LabelAndControl: NSObject { return checkbox } - static func makeInfoButton(size: CGFloat = 18, + static func makeInfoButton(size: CGFloat = 16, onClick: EventClosure? = nil, onMouseEntered: EventClosure? = nil, onMouseExited: EventClosure? = nil) -> ClickHoverImageView { @@ -320,7 +321,7 @@ class LabelAndControl: NSObject { } // some preferences require re-creating some components if (!(senderControl is NSSlider) || (NSEvent.pressedMouseButtons & (1 << 0)) == 0) && - (["appearanceStyle", "appearanceSize", "appearanceTheme", "appearanceVisibility", "titleTruncation", "showOnScreen", "showAppsOrWindows"].contains { (pref: String) -> Bool in + (["appearanceStyle", "appearanceSize", "appearanceTheme", "appearanceVisibility", "showOnScreen", "showAppsOrWindows"].contains { (pref: String) -> Bool in pref == senderControl.identifier!.rawValue }) { (App.shared as! App).resetPreferencesDependentComponents() diff --git a/src/ui/preferences-window/tabs/appearance/AppearanceTab.swift b/src/ui/preferences-window/tabs/appearance/AppearanceTab.swift index 9f278807..46565e96 100644 --- a/src/ui/preferences-window/tabs/appearance/AppearanceTab.swift +++ b/src/ui/preferences-window/tabs/appearance/AppearanceTab.swift @@ -27,8 +27,8 @@ class IllustratedImageThemeView: ClickHoverImageView { imageView.translatesAutoresizingMaskIntoConstraints = false imageView.imageScaling = .scaleProportionallyUpOrDown imageView.wantsLayer = true - imageView.layer?.masksToBounds = true - imageView.layer?.cornerRadius = TableGroupView.cornerRadius + imageView.layer!.masksToBounds = true + imageView.layer!.cornerRadius = TableGroupView.cornerRadius super.init(infoCircle: imageView) self.style = style