| | |
| | | private var statusItem: NSStatusItem? |
| | | private var overlayWindow: NSWindow? |
| | | private var overlayKeyMonitor: Any? |
| | | private var quickSearchLocalMouseMonitor: Any? |
| | | private var quickSearchExternalMouseMonitor: Any? |
| | | private var settingsWindow: NSWindow? // Track Settings window to keep it above overlay |
| | | private var mainHotkeyRef: EventHotKeyRef? |
| | |
| | | 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 struct ForeignWindowFrame { |
| | | let owner: String |
| | | let frame: NSRect |
| | | } |
| | | |
| | |
| | | let showDock = UserDefaults.standard.bool(forKey: Self.showDockIconKey) |
| | | lastShowDockIcon = showDock |
| | | |
| | | let shouldStayAccessoryForCurrentFullscreenSpace = avoidSpaceSwitch |
| | | && isOverlayVisible |
| | | && !isSettingsVisible |
| | | let shouldStayAccessoryForCurrentFullscreenSpace = isOverlayVisible |
| | | && (avoidSpaceSwitch || overlayAvoidsSpaceSwitch) |
| | | let desiredPolicy: NSApplication.ActivationPolicy = shouldStayAccessoryForCurrentFullscreenSpace |
| | | ? .accessory |
| | | : (requiresForegroundOwnership |
| | |
| | | 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) |
| | |
| | | existingWindow.orderOut(nil) |
| | | overlayWindow = nil |
| | | } |
| | | overlayAvoidsSpaceSwitch = shouldAvoidSpaceSwitch |
| | | |
| | | let window = makeOverlayWindow( |
| | | on: placement.screen, |
| | |
| | | |
| | | 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.contains { info in |
| | | return windows.compactMap { info in |
| | | guard let owner = info[kCGWindowOwnerName as String] as? String, |
| | | owner != AppIdentity.displayName, |
| | | owner != "Window Server", |
| | |
| | | let layer = info[kCGWindowLayer as String] as? Int, |
| | | layer == 0, |
| | | let bounds = info[kCGWindowBounds as String] as? NSDictionary |
| | | else { return false } |
| | | else { return nil } |
| | | |
| | | let x = cgWindowDimension(bounds, "X") |
| | | let y = cgWindowDimension(bounds, "Y") |
| | | let width = cgWindowDimension(bounds, "Width") |
| | | let height = cgWindowDimension(bounds, "Height") |
| | | let windowFrame = NSRect(x: x, y: y, width: width, height: height) |
| | | let widthMatches = abs(windowFrame.width - screenFrame.width) <= 12 |
| | | let heightMatches = windowFrame.height >= screenFrame.height * 0.88 |
| | | let horizontallyAligned = abs(windowFrame.midX - screenFrame.midX) <= 12 |
| | | return widthMatches && heightMatches && horizontallyAligned |
| | | 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 { |
| | |
| | | overlayWindow?.orderOut(nil) |
| | | removeOverlayKeyMonitor() |
| | | removeQuickSearchExternalMouseMonitor() |
| | | overlayAvoidsSpaceSwitch = false |
| | | refreshLauncherChromeState() |
| | | NotificationCenter.default.post(name: .tagLauncherOverlayDidHide, object: nil) |
| | | if discardWindow { |
| | |
| | | |
| | | private func showQuickSearchFromGlobalHotkey() { |
| | | if overlayWindow?.isVisible == true { |
| | | refreshLauncherChromeState(activate: true) |
| | | refreshLauncherChromeState( |
| | | activate: !overlayAvoidsSpaceSwitch, |
| | | avoidSpaceSwitch: overlayAvoidsSpaceSwitch |
| | | ) |
| | | requestQuickSearch(source: QuickSearchOpenSource.globalVisible) |
| | | return |
| | | } |
| | |
| | | |
| | | /// Settings must always appear centered over the current overlay view and float above it. |
| | | private func prepareSettingsWindow(_ window: NSWindow) { |
| | | dismissQuickSearchIfNeeded() |
| | | let settingsSize = NSSize(width: 880, height: 460) |
| | | window.identifier = NSUserInterfaceItemIdentifier("TagLauncherPreferencesWindow") |
| | | window.minSize = settingsSize |
| | |
| | | |
| | | var behavior = window.collectionBehavior |
| | | behavior.remove(.canJoinAllSpaces) |
| | | behavior.formUnion([.fullScreenAuxiliary, .moveToActiveSpace]) |
| | | if overlayAvoidsSpaceSwitch { |
| | | behavior.remove(.moveToActiveSpace) |
| | | behavior.formUnion([.fullScreenAuxiliary, .stationary, .transient, .ignoresCycle]) |
| | | } else { |
| | | behavior.formUnion([.fullScreenAuxiliary, .moveToActiveSpace]) |
| | | } |
| | | window.collectionBehavior = behavior |
| | | window.makeKeyAndOrderFront(nil) |
| | | window.orderFrontRegardless() |
| | | settingsWindow = window |
| | | refreshLauncherChromeState(activate: true) |
| | | refreshLauncherChromeState( |
| | | activate: !overlayAvoidsSpaceSwitch, |
| | | avoidSpaceSwitch: overlayAvoidsSpaceSwitch |
| | | ) |
| | | } |
| | | |
| | | private func attachSettingsWindow(_ window: NSWindow, to overlayWindow: NSWindow) { |
| | |
| | | let preferredScreen = self.overlayWindow?.screen |
| | | self.detachSettingsWindow(closingWindow) |
| | | self.settingsWindow = nil |
| | | self.refreshLauncherChromeState(activate: shouldRefocusOverlay) |
| | | self.refreshLauncherChromeState( |
| | | activate: shouldRefocusOverlay && !self.overlayAvoidsSpaceSwitch, |
| | | avoidSpaceSwitch: self.overlayAvoidsSpaceSwitch |
| | | ) |
| | | guard shouldRefocusOverlay else { return } |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in |
| | | self?.showOrFocusOverlay(preferredScreen: preferredScreen) |
| | |
| | | } |
| | | |
| | | private func promoteOverlayToForegroundInput() { |
| | | if overlayAvoidsSpaceSwitch { |
| | | refreshLauncherChromeState(activate: false, avoidSpaceSwitch: true) |
| | | guard let overlayWindow else { return } |
| | | overlayWindow.makeKeyAndOrderFront(nil) |
| | | overlayWindow.orderFrontRegardless() |
| | | return |
| | | } |
| | | beginLauncherForegroundOwnership() |
| | | guard let overlayWindow else { return } |
| | | overlayWindow.makeKeyAndOrderFront(nil) |
| | |
| | | } |
| | | |
| | | private func installQuickSearchExternalMouseMonitor() { |
| | | installQuickSearchLocalMouseMonitor() |
| | | guard quickSearchExternalMouseMonitor == nil else { return } |
| | | quickSearchExternalMouseMonitor = NSEvent.addGlobalMonitorForEvents( |
| | | matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] |
| | |
| | | } |
| | | } |
| | | |
| | | private func installQuickSearchLocalMouseMonitor() { |
| | | guard quickSearchLocalMouseMonitor == nil else { return } |
| | | quickSearchLocalMouseMonitor = NSEvent.addLocalMonitorForEvents( |
| | | matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] |
| | | ) { [weak self] event in |
| | | guard let self, self.isQuickSearchOpen else { return event } |
| | | if event.window === self.overlayWindow { |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | return nil |
| | | } |
| | | return event |
| | | } |
| | | } |
| | | |
| | | private func removeQuickSearchExternalMouseMonitor() { |
| | | if let quickSearchLocalMouseMonitor { |
| | | NSEvent.removeMonitor(quickSearchLocalMouseMonitor) |
| | | self.quickSearchLocalMouseMonitor = nil |
| | | } |
| | | if let quickSearchExternalMouseMonitor { |
| | | NSEvent.removeMonitor(quickSearchExternalMouseMonitor) |
| | | self.quickSearchExternalMouseMonitor = nil |
| | |
| | | } |
| | | |
| | | @objc private func openPreferences(_ sender: Any? = nil) { |
| | | dismissQuickSearchIfNeeded() |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | beginLauncherForegroundOwnership() |
| | | if overlayAvoidsSpaceSwitch { |
| | | refreshLauncherChromeState(activate: false, avoidSpaceSwitch: true) |
| | | } else { |
| | | beginLauncherForegroundOwnership() |
| | | } |
| | | // Don't hide overlay — keep it visible for real-time setting preview. |
| | | if let overlayWindow, overlayWindow.isVisible { |
| | | overlayWindow.makeKeyAndOrderFront(nil) |
| | |
| | | prepareSettingsWindow(window) |
| | | } |
| | | |
| | | private func dismissQuickSearchIfNeeded() { |
| | | guard isQuickSearchOpen else { return } |
| | | isQuickSearchOpen = false |
| | | removeQuickSearchExternalMouseMonitor() |
| | | updateOverlayLevelForTextInput() |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | } |
| | | |
| | | @objc private func switchLanguage(_ sender: NSMenuItem) { |
| | | guard let code = sender.representedObject as? String else { return } |
| | | L10n.switchTo(code) |