| | |
| | | |
| | | // MARK: - App Delegate (menubar + overlay window + hotkey) |
| | | |
| | | final class OverlayPanel: NSPanel { |
| | | override var canBecomeKey: Bool { true } |
| | | override var canBecomeMain: Bool { true } |
| | | |
| | | override func constrainFrameRect(_ frameRect: NSRect, to screen: NSScreen?) -> NSRect { |
| | | frameRect |
| | | } |
| | | } |
| | | |
| | | final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { |
| | | private static let showDockIconKey = "showDockIcon" |
| | | private static let statusItemAutosaveName = AppIdentity.statusItemAutosaveName |
| | |
| | | private static let overlayTextInputLevel = launcherOverlayLevel |
| | | |
| | | private var statusItem: NSStatusItem? |
| | | private var overlayWindow: NSWindow? |
| | | private var overlayKeyMonitor: Any? |
| | | private var quickSearchLocalMouseMonitor: Any? |
| | | private var quickSearchExternalMouseMonitor: Any? |
| | |
| | | private var isConfiguringApplicationMenu = false |
| | | private var lastShowDockIcon: Bool? |
| | | private var statusMenuScreenForNextOverlay: NSScreen? |
| | | private var overlayGeneration = 0 |
| | | private var overlayAvoidsSpaceSwitch = false |
| | | private var suppressReopenUntil = Date.distantPast |
| | | |
| | | private struct OverlayPlacementContext { |
| | | let screen: NSScreen |
| | | let frame: NSRect |
| | | private lazy var overlayController = OverlayWindowController( |
| | | dependencies: OverlayWindowController.Dependencies( |
| | | shouldStageAsAccessory: { [weak self] in |
| | | self?.shouldStageOverlayAsAccessory ?? false |
| | | }, |
| | | isSettingsVisible: { [weak self] in |
| | | self?.isSettingsVisible ?? false |
| | | }, |
| | | settingsWindow: { [weak self] in |
| | | self?.settingsWindow |
| | | }, |
| | | canHideOverlay: { [weak self] in |
| | | self?.isInEditMode == false |
| | | }, |
| | | currentOverlayLevel: { [weak self] in |
| | | self?.currentOverlayLevel ?? Self.overlayDefaultLevel |
| | | }, |
| | | overlayLevel: { [weak self] initialQuickSearchSource in |
| | | self?.overlayLevel(initialQuickSearchSource: initialQuickSearchSource) ?? Self.overlayDefaultLevel |
| | | }, |
| | | makeContentView: { [weak self] initialQuickSearchSource in |
| | | DismissibleHostingView( |
| | | rootView: ContentView( |
| | | hideOverlay: { [weak self] in |
| | | self?.hideOverlay(force: true) |
| | | }, |
| | | initialQuickSearchSource: initialQuickSearchSource |
| | | ), |
| | | onBackdropTap: { [weak self] in |
| | | self?.hideOverlay() |
| | | } |
| | | ) |
| | | }, |
| | | installOverlayKeyMonitor: { [weak self] in |
| | | self?.installOverlayKeyMonitor() |
| | | }, |
| | | removeOverlayKeyMonitor: { [weak self] in |
| | | self?.removeOverlayKeyMonitor() |
| | | }, |
| | | removeQuickSearchMouseMonitor: { [weak self] in |
| | | self?.removeQuickSearchExternalMouseMonitor() |
| | | }, |
| | | detachSettingsWindow: { [weak self] window in |
| | | self?.detachSettingsWindow(window) |
| | | }, |
| | | prepareSettingsWindow: { [weak self] window in |
| | | self?.prepareSettingsWindow(window) |
| | | }, |
| | | refreshChromeState: { [weak self] activate, avoidSpaceSwitch in |
| | | self?.refreshLauncherChromeState(activate: activate, avoidSpaceSwitch: avoidSpaceSwitch) |
| | | }, |
| | | onWillHide: { |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | }, |
| | | onDidHide: { |
| | | NotificationCenter.default.post(name: .tagLauncherOverlayDidHide, object: nil) |
| | | }, |
| | | onDidShow: { |
| | | NotificationCenter.default.post(name: .tagLauncherOverlayDidShow, object: nil) |
| | | } |
| | | ) |
| | | ) |
| | | |
| | | private var overlayWindow: NSWindow? { |
| | | overlayController.window |
| | | } |
| | | |
| | | private struct ForeignWindowFrame { |
| | | let owner: String |
| | | let frame: NSRect |
| | | private var overlayGeneration: Int { |
| | | overlayController.generation |
| | | } |
| | | |
| | | private var overlayAvoidsSpaceSwitch: Bool { |
| | | get { overlayController.avoidsSpaceSwitch } |
| | | set { overlayController.avoidsSpaceSwitch = newValue } |
| | | } |
| | | |
| | | private var isOverlayVisible: Bool { |
| | |
| | | } |
| | | |
| | | func menuWillOpen(_ menu: NSMenu) { |
| | | statusMenuScreenForNextOverlay = screenContainingCurrentPointer() |
| | | statusMenuScreenForNextOverlay = overlayController.screenContainingCurrentPointer() |
| | | ?? statusItem?.button?.window?.screen |
| | | ?? NSScreen.main |
| | | ?? NSScreen.screens.first |
| | |
| | | } |
| | | |
| | | private func performToggleOverlay(preferredScreen: NSScreen?) { |
| | | if overlayWindow?.isVisible == true { |
| | | hideOverlay(force: true) |
| | | } else { |
| | | showOverlay(preferredScreen: preferredScreen) |
| | | } |
| | | overlayController.toggle(preferredScreen: preferredScreen) |
| | | } |
| | | |
| | | private func showOrFocusOverlay(preferredScreen: NSScreen? = nil) { |
| | | let placement = overlayPlacementContextForNextOverlay(preferredScreen: preferredScreen) |
| | | let shouldAvoidSpaceSwitch = placement.map { hasFullscreenWindowOnScreen($0.screen) } ?? false |
| | | if let overlayWindow, overlayWindow.isVisible { |
| | | overlayAvoidsSpaceSwitch = shouldAvoidSpaceSwitch |
| | | moveOverlayToCurrentPlacement(preferredScreen: preferredScreen) |
| | | overlayWindow.level = currentOverlayLevel |
| | | refreshLauncherChromeState(activate: !shouldAvoidSpaceSwitch, avoidSpaceSwitch: shouldAvoidSpaceSwitch) |
| | | overlayWindow.makeKeyAndOrderFront(nil) |
| | | overlayWindow.orderFrontRegardless() |
| | | if let settingsWindow, settingsWindow.isVisible { |
| | | prepareSettingsWindow(settingsWindow) |
| | | } |
| | | return |
| | | } |
| | | showOverlay(preferredScreen: preferredScreen) |
| | | } |
| | | |
| | | private func moveOverlayToCurrentPlacement(preferredScreen: NSScreen?) { |
| | | guard let overlayWindow, |
| | | let placement = overlayPlacementContextForNextOverlay(preferredScreen: preferredScreen), |
| | | overlayWindow.frame != placement.frame |
| | | else { return } |
| | | overlayWindow.setFrame(placement.frame, display: true) |
| | | overlayController.showOrFocus(preferredScreen: preferredScreen) |
| | | } |
| | | |
| | | private func showOverlay( |
| | |
| | | preferredScreen: NSScreen? = nil, |
| | | stagedForAllSpaces: Bool = false |
| | | ) { |
| | | guard let placement = overlayPlacementContextForNextOverlay(preferredScreen: preferredScreen) else { return } |
| | | |
| | | let shouldAvoidSpaceSwitch = hasFullscreenWindowOnScreen(placement.screen) |
| | | let shouldStageAsAccessory = shouldStageOverlayAsAccessory || shouldAvoidSpaceSwitch |
| | | |
| | | if !stagedForAllSpaces && shouldStageAsAccessory { |
| | | // Let the all-spaces panel attach to the pointer's display before the app reclaims focus. |
| | | if NSApp.activationPolicy() != .accessory { |
| | | NSApp.setActivationPolicy(.accessory) |
| | | } |
| | | if NSApp.isActive { |
| | | NSApp.deactivate() |
| | | } |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in |
| | | self?.showOverlay( |
| | | initialQuickSearchSource: initialQuickSearchSource, |
| | | preferredScreen: placement.screen, |
| | | stagedForAllSpaces: true |
| | | ) |
| | | } |
| | | return |
| | | } |
| | | |
| | | if overlayWindow?.isVisible == true { |
| | | hideOverlay(force: true) |
| | | } else if let existingWindow = overlayWindow { |
| | | existingWindow.orderOut(nil) |
| | | overlayWindow = nil |
| | | } |
| | | overlayAvoidsSpaceSwitch = shouldAvoidSpaceSwitch |
| | | |
| | | let window = makeOverlayWindow( |
| | | on: placement.screen, |
| | | initialQuickSearchSource: initialQuickSearchSource |
| | | overlayController.show( |
| | | initialQuickSearchSource: initialQuickSearchSource, |
| | | preferredScreen: preferredScreen, |
| | | stagedForAllSpaces: stagedForAllSpaces |
| | | ) |
| | | overlayGeneration &+= 1 |
| | | overlayWindow = window |
| | | installOverlayKeyMonitor() |
| | | if shouldStageAsAccessory { |
| | | if NSApp.isActive { |
| | | NSApp.deactivate() |
| | | } |
| | | if NSApp.activationPolicy() != .accessory { |
| | | NSApp.setActivationPolicy(.accessory) |
| | | } |
| | | } |
| | | |
| | | let targetLevel = overlayLevel(initialQuickSearchSource: initialQuickSearchSource) |
| | | window.setFrame(placement.frame, display: true) |
| | | window.level = targetLevel |
| | | |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | |
| | | let placementFrame = placement.frame |
| | | let finishForegroundClaim: () -> Void = { [weak self, weak window] in |
| | | guard let self, let window, self.overlayWindow === window else { return } |
| | | self.refreshLauncherChromeState(activate: !shouldAvoidSpaceSwitch, avoidSpaceSwitch: shouldAvoidSpaceSwitch) |
| | | if window.frame != placementFrame { |
| | | window.setFrame(placementFrame, display: true) |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | } |
| | | if let settingsWindow = self.settingsWindow, settingsWindow.isVisible { |
| | | self.prepareSettingsWindow(settingsWindow) |
| | | } |
| | | NotificationCenter.default.post(name: .tagLauncherOverlayDidShow, object: nil) |
| | | } |
| | | |
| | | if !isSettingsVisible { |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: finishForegroundClaim) |
| | | } else { |
| | | finishForegroundClaim() |
| | | } |
| | | } |
| | | |
| | | private func hasFullscreenWindowOnScreen(_ screen: NSScreen) -> Bool { |
| | | let screenFrame = screen.frame |
| | | let windowFrames = foreignLayerZeroWindows(on: screenFrame) |
| | | |
| | | if windowFrames.contains(where: { isSingleFullscreenWindow($0.frame, on: screenFrame) }) { |
| | | return true |
| | | } |
| | | |
| | | return hasSplitViewFullscreenWindows(windowFrames, on: screenFrame) |
| | | } |
| | | |
| | | private func foreignLayerZeroWindows(on screenFrame: NSRect) -> [ForeignWindowFrame] { |
| | | let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] ?? [] |
| | | return windows.compactMap { info in |
| | | guard let owner = info[kCGWindowOwnerName as String] as? String, |
| | | owner != AppIdentity.displayName, |
| | | owner != "Window Server", |
| | | owner != "Dock", |
| | | owner != "loginwindow", |
| | | let layer = info[kCGWindowLayer as String] as? Int, |
| | | layer == 0, |
| | | let bounds = info[kCGWindowBounds as String] as? NSDictionary |
| | | else { return nil } |
| | | |
| | | let frame = NSRect( |
| | | x: cgWindowDimension(bounds, "X"), |
| | | y: cgWindowDimension(bounds, "Y"), |
| | | width: cgWindowDimension(bounds, "Width"), |
| | | height: cgWindowDimension(bounds, "Height") |
| | | ) |
| | | guard frame.intersects(screenFrame) else { return nil } |
| | | return ForeignWindowFrame(owner: owner, frame: frame) |
| | | } |
| | | } |
| | | |
| | | private func isSingleFullscreenWindow(_ windowFrame: NSRect, on screenFrame: NSRect) -> Bool { |
| | | let widthMatches = abs(windowFrame.width - screenFrame.width) <= 12 |
| | | let heightMatches = windowFrame.height >= screenFrame.height * 0.88 |
| | | let horizontallyAligned = abs(windowFrame.midX - screenFrame.midX) <= 12 |
| | | let verticallyAligned = abs(windowFrame.maxY - screenFrame.maxY) <= 32 |
| | | return widthMatches && heightMatches && horizontallyAligned && verticallyAligned |
| | | } |
| | | |
| | | private func hasSplitViewFullscreenWindows(_ windows: [ForeignWindowFrame], on screenFrame: NSRect) -> Bool { |
| | | let clippedWindows = windows.map { |
| | | ForeignWindowFrame(owner: $0.owner, frame: $0.frame.intersection(screenFrame)) |
| | | } |
| | | let tallWindows = clippedWindows |
| | | .filter { window in |
| | | let frame = window.frame |
| | | return frame.height >= screenFrame.height * 0.86 |
| | | && frame.width >= screenFrame.width * 0.20 |
| | | && frame.width <= screenFrame.width * 0.86 |
| | | && abs(frame.maxY - screenFrame.maxY) <= 32 |
| | | } |
| | | .sorted { $0.frame.minX < $1.frame.minX } |
| | | |
| | | guard tallWindows.count >= 2 else { return false } |
| | | |
| | | for startIndex in tallWindows.indices { |
| | | var union = tallWindows[startIndex].frame |
| | | var lastMaxX = union.maxX |
| | | |
| | | for window in tallWindows.dropFirst(startIndex + 1) { |
| | | let gap = window.frame.minX - lastMaxX |
| | | if gap < -32 || gap > 48 { |
| | | break |
| | | } |
| | | union = union.union(window.frame) |
| | | lastMaxX = max(lastMaxX, window.frame.maxX) |
| | | |
| | | let touchesLeft = abs(union.minX - screenFrame.minX) <= 32 |
| | | let touchesRight = abs(union.maxX - screenFrame.maxX) <= 32 |
| | | let coversWidth = union.width >= screenFrame.width * 0.92 |
| | | let coversHeight = union.height >= screenFrame.height * 0.86 |
| | | if touchesLeft && touchesRight && coversWidth && coversHeight { |
| | | return true |
| | | } |
| | | } |
| | | } |
| | | |
| | | return false |
| | | } |
| | | |
| | | private func cgWindowDimension(_ bounds: NSDictionary, _ key: String) -> CGFloat { |
| | | if let value = bounds[key] as? CGFloat { |
| | | return value |
| | | } |
| | | if let value = bounds[key] as? NSNumber { |
| | | return CGFloat(truncating: value) |
| | | } |
| | | return 0 |
| | | } |
| | | |
| | | private func installOverlayKeyMonitor() { |
| | |
| | | } |
| | | } |
| | | |
| | | private func makeOverlayWindow(on screen: NSScreen, initialQuickSearchSource: String? = nil) -> NSWindow { |
| | | let panel = OverlayPanel( |
| | | contentRect: screen.frame, |
| | | styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel], |
| | | backing: .buffered, |
| | | defer: false |
| | | ) |
| | | panel.isFloatingPanel = true |
| | | panel.hidesOnDeactivate = false |
| | | // Keep the overlay publishable above fullscreen Spaces without asking |
| | | // AppKit to migrate an all-spaces window between Spaces. |
| | | panel.collectionBehavior = [ |
| | | .canJoinAllSpaces, |
| | | .fullScreenAuxiliary, |
| | | .stationary, |
| | | .transient, |
| | | .ignoresCycle |
| | | ] |
| | | panel.isOpaque = false |
| | | panel.backgroundColor = NSColor.black.withAlphaComponent(0.001) |
| | | panel.hasShadow = false |
| | | panel.titlebarAppearsTransparent = true |
| | | panel.titleVisibility = .hidden |
| | | panel.isReleasedWhenClosed = false |
| | | panel.contentView = DismissibleHostingView( |
| | | rootView: ContentView( |
| | | hideOverlay: { [weak self] in |
| | | self?.hideOverlay(force: true) |
| | | }, |
| | | initialQuickSearchSource: initialQuickSearchSource |
| | | ), |
| | | onBackdropTap: { [weak self] in |
| | | self?.hideOverlay() |
| | | } |
| | | ) |
| | | return panel |
| | | } |
| | | |
| | | private func hideOverlay(force: Bool = false, discardWindow: Bool = false) { |
| | | guard force || !isInEditMode else { return } |
| | | overlayGeneration &+= 1 |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | if let settingsWindow, settingsWindow.parent == overlayWindow { |
| | | detachSettingsWindow(settingsWindow) |
| | | } |
| | | overlayWindow?.orderOut(nil) |
| | | removeOverlayKeyMonitor() |
| | | removeQuickSearchExternalMouseMonitor() |
| | | overlayAvoidsSpaceSwitch = false |
| | | refreshLauncherChromeState() |
| | | NotificationCenter.default.post(name: .tagLauncherOverlayDidHide, object: nil) |
| | | if discardWindow { |
| | | overlayWindow = nil |
| | | } |
| | | overlayController.hide(force: force, discardWindow: discardWindow) |
| | | } |
| | | |
| | | // MARK: - Global Hotkeys |
| | |
| | | } |
| | | |
| | | private func screenUnderMouse() -> NSScreen? { |
| | | overlayPlacementContextForNextOverlay(preferredScreen: nil)?.screen |
| | | } |
| | | |
| | | private func overlayPlacementContextForNextOverlay(preferredScreen: NSScreen?) -> OverlayPlacementContext? { |
| | | if let screen = preferredScreen { |
| | | return OverlayPlacementContext(screen: screen, frame: screen.frame) |
| | | } |
| | | guard let screen = screenContainingCurrentPointer() ?? NSScreen.main ?? NSScreen.screens.first else { |
| | | return nil |
| | | } |
| | | return OverlayPlacementContext(screen: screen, frame: screen.frame) |
| | | } |
| | | |
| | | private func screenContainingCurrentPointer() -> NSScreen? { |
| | | let mousePoint = NSEvent.mouseLocation |
| | | return NSScreen.screens.first(where: { |
| | | NSMouseInRect(mousePoint, $0.frame, false) |
| | | }) |
| | | overlayController.screenUnderMouse() |
| | | } |
| | | |
| | | /// Clean up settingsWindow reference when the Settings window closes. |