| | |
| | | private static let downloadHelpMenuItemIdentifier = NSUserInterfaceItemIdentifier("TagLauncherDownloadHelpMenuItem") |
| | | private static let externalActivationNotification = Notification.Name("TagLauncherExternalActivationRequested") |
| | | private static let externalActivationObject = AppIdentity.bundleIdentifier |
| | | private static let duplicateLaunchSuppressReopenKey = "duplicateLaunchSuppressReopenAt" |
| | | private static let launcherOverlayLevel = NSWindow.Level(rawValue: NSWindow.Level.mainMenu.rawValue - 1) |
| | | private static let overlayDefaultLevel = launcherOverlayLevel |
| | | private static let overlayTextInputLevel = launcherOverlayLevel |
| | |
| | | private var lastShowDockIcon: Bool? |
| | | private var statusMenuScreenForNextOverlay: NSScreen? |
| | | private var suppressReopenUntil = Date.distantPast |
| | | private var overlayOpenedByQuickSearchOnly = false |
| | | |
| | | private lazy var overlayController = OverlayWindowController( |
| | | dependencies: OverlayWindowController.Dependencies( |
| | |
| | | self?.removeOverlayKeyMonitor() |
| | | }, |
| | | removeQuickSearchMouseMonitor: { [weak self] in |
| | | Diagnostics.log("app.quickSearch.removeMouseMonitor", [ |
| | | "quickSearchOpen": self?.isQuickSearchOpen, |
| | | "quickSearchOnlyOverlaySession": self?.quickSearchOnlyOverlaySession, |
| | | "quickSearchShouldHideOverlayOnClose": self?.quickSearchShouldHideOverlayOnClose |
| | | ]) |
| | | self?.removeQuickSearchExternalMouseMonitor() |
| | | self?.quickSearchShouldHideOverlayOnClose = false |
| | | self?.quickSearchOnlyOverlaySession = false |
| | |
| | | onWillHide: { |
| | | TagDatabase.flushPendingCategorySchemeBackupBatch() |
| | | }, |
| | | onDidHide: { |
| | | onDidHide: { [weak self] in |
| | | self?.overlayOpenedByQuickSearchOnly = false |
| | | NotificationCenter.default.post(name: .tagLauncherOverlayDidHide, object: nil) |
| | | }, |
| | | onDidShow: { |
| | |
| | | configureApplicationMenuWhenAvailable(retries: 12) |
| | | } |
| | | |
| | | /// Dock icon click → show overlay (same as menubar "Show TagLauncher") |
| | | /// Dock icon reopen is an explicit App Grid entry only when the user chooses to show the Dock icon. |
| | | /// Duplicate-instance handoff suppresses this path so repeated launches do not show App Grid. |
| | | func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { |
| | | guard Date() >= suppressReopenUntil else { return false } |
| | | let lastDuplicateLaunch = UserDefaults.standard.double(forKey: Self.duplicateLaunchSuppressReopenKey) |
| | | if lastDuplicateLaunch > 0, |
| | | Date().timeIntervalSince1970 - lastDuplicateLaunch < 2.0 { |
| | | suppressReopenUntil = Date().addingTimeInterval(1.0) |
| | | return false |
| | | } |
| | | guard UserDefaults.standard.bool(forKey: Self.showDockIconKey) else { return false } |
| | | guard isRecentUserClickNearDockArea() else { return false } |
| | | showOrFocusOverlay() |
| | | return false // Suppress default "unhide all windows" behavior |
| | | } |
| | | |
| | | private func isRecentUserClickNearDockArea() -> Bool { |
| | | let lastMouseDown = CGEventSource.secondsSinceLastEventType( |
| | | .combinedSessionState, |
| | | eventType: .leftMouseDown |
| | | ) |
| | | let lastMouseUp = CGEventSource.secondsSinceLastEventType( |
| | | .combinedSessionState, |
| | | eventType: .leftMouseUp |
| | | ) |
| | | let recentMouseClick = min(lastMouseDown, lastMouseUp) < 1.2 |
| | | return recentMouseClick && isPointerNearDockArea() |
| | | } |
| | | |
| | | private func isPointerNearDockArea() -> Bool { |
| | | let mouse = NSEvent.mouseLocation |
| | | return NSScreen.screens.contains { screen in |
| | | let frame = screen.frame |
| | | let visible = screen.visibleFrame |
| | | guard NSMouseInRect(mouse, frame, false) else { return false } |
| | | let bottomDock = visible.minY > frame.minY |
| | | && mouse.y >= frame.minY |
| | | && mouse.y <= visible.minY + 24 |
| | | let leftDock = visible.minX > frame.minX |
| | | && mouse.x >= frame.minX |
| | | && mouse.x <= visible.minX + 24 |
| | | let rightDock = visible.maxX < frame.maxX |
| | | && mouse.x <= frame.maxX |
| | | && mouse.x >= visible.maxX - 24 |
| | | return bottomDock || leftDock || rightDock |
| | | } |
| | | } |
| | | |
| | | func applicationWillTerminate(_ notification: Notification) { |
| | |
| | | } |
| | | |
| | | private func beginLauncherForegroundOwnership(activate: Bool = true, keyWindow: NSWindow? = nil) { |
| | | if NSApp.activationPolicy() != .regular { |
| | | NSApp.setActivationPolicy(.regular) |
| | | let showDock = UserDefaults.standard.bool(forKey: Self.showDockIconKey) |
| | | let desiredPolicy: NSApplication.ActivationPolicy = showDock ? .regular : .accessory |
| | | if NSApp.activationPolicy() != desiredPolicy { |
| | | NSApp.setActivationPolicy(desiredPolicy) |
| | | } |
| | | if activate { |
| | | if activate, showDock { |
| | | claimLauncherForeground(keyWindow: keyWindow) |
| | | } else if activate { |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | keyWindow?.makeKeyAndOrderFront(nil) |
| | | keyWindow?.orderFrontRegardless() |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | let shouldStayAccessoryForCurrentFullscreenSpace = isOverlayVisible |
| | | && (avoidSpaceSwitch || overlayAvoidsSpaceSwitch) |
| | | let shouldStayAccessoryForHiddenDockChrome = requiresForegroundOwnership |
| | | && !showDock |
| | | let shouldStayAccessoryForQuickOnlySearch = isOverlayVisible |
| | | && quickSearchOnlyOverlaySession |
| | | && !showDock |
| | | let desiredPolicy: NSApplication.ActivationPolicy = shouldStayAccessoryForCurrentFullscreenSpace |
| | | ? .accessory |
| | | : (shouldStayAccessoryForHiddenDockChrome ? .accessory |
| | | : (shouldStayAccessoryForQuickOnlySearch ? .accessory |
| | | : (requiresForegroundOwnership |
| | | ? .regular |
| | | : (showDock ? .regular : .accessory))) |
| | | : (showDock ? .regular : .accessory)))) |
| | | if NSApp.activationPolicy() != desiredPolicy { |
| | | NSApp.setActivationPolicy(desiredPolicy) |
| | | } |
| | |
| | | |
| | | if activate && requiresForegroundOwnership |
| | | && !shouldStayAccessoryForCurrentFullscreenSpace |
| | | && !shouldStayAccessoryForHiddenDockChrome |
| | | && !shouldStayAccessoryForQuickOnlySearch { |
| | | let keyWindow = isSettingsVisible ? settingsWindow : (isOverlayVisible ? overlayWindow : nil) |
| | | claimLauncherForeground( |
| | |
| | | } |
| | | |
| | | if isQuickSearchOpen { |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchDismissRequested, |
| | | object: nil, |
| | | userInfo: ["source": QuickSearchDismissSource.programmatic] |
| | | ) |
| | | } |
| | | |
| | | if !requiresForegroundOwnership { |
| | |
| | | } |
| | | |
| | | private func performToggleOverlay(preferredScreen: NSScreen?) { |
| | | overlayOpenedByQuickSearchOnly = false |
| | | overlayController.toggle(preferredScreen: preferredScreen) |
| | | } |
| | | |
| | | private func showOrFocusOverlay(preferredScreen: NSScreen? = nil) { |
| | | overlayOpenedByQuickSearchOnly = false |
| | | overlayController.showOrFocus(preferredScreen: preferredScreen) |
| | | } |
| | | |
| | |
| | | preferredScreen: NSScreen? = nil, |
| | | stagedForAllSpaces: Bool = false |
| | | ) { |
| | | Diagnostics.log("app.overlay.showRequest", [ |
| | | "initialQuickSearchSource": initialQuickSearchSource, |
| | | "stagedForAllSpaces": stagedForAllSpaces, |
| | | "isQuickSearchOpen": isQuickSearchOpen, |
| | | "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession, |
| | | "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly |
| | | ]) |
| | | if initialQuickSearchSource == QuickSearchOpenSource.globalHidden { |
| | | isQuickSearchOpen = true |
| | | quickSearchShouldHideOverlayOnClose = true |
| | | quickSearchOnlyOverlaySession = true |
| | | overlayOpenedByQuickSearchOnly = true |
| | | } else if initialQuickSearchSource == nil { |
| | | overlayOpenedByQuickSearchOnly = false |
| | | } |
| | | overlayController.show( |
| | | initialQuickSearchSource: initialQuickSearchSource, |
| | |
| | | |
| | | private func handleOverlayEscapeKey() -> Bool { |
| | | if isQuickSearchOpen || quickSearchOnlyOverlaySession { |
| | | let shouldHideOverlayAfterDismiss = quickSearchShouldHideOverlayOnClose || quickSearchOnlyOverlaySession |
| | | isQuickSearchOpen = false |
| | | removeQuickSearchExternalMouseMonitor() |
| | | updateOverlayLevelForTextInput() |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | if shouldHideOverlayAfterDismiss { |
| | | quickSearchShouldHideOverlayOnClose = false |
| | | quickSearchOnlyOverlaySession = false |
| | | DispatchQueue.main.async { [weak self] in |
| | | self?.hideOverlay(force: true) |
| | | } |
| | | } |
| | | dismissQuickSearchFromKeyboard() |
| | | return true |
| | | } |
| | | guard !isSettingsVisible, |
| | |
| | | return true |
| | | } |
| | | |
| | | private func dismissQuickSearchFromKeyboard() { |
| | | let shouldHideOverlayAfterDismiss = quickSearchShouldHideOverlayOnClose || quickSearchOnlyOverlaySession |
| | | isQuickSearchOpen = false |
| | | removeQuickSearchExternalMouseMonitor() |
| | | updateOverlayLevelForTextInput() |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchDismissRequested, |
| | | object: nil, |
| | | userInfo: ["source": QuickSearchDismissSource.keyboard] |
| | | ) |
| | | if shouldHideOverlayAfterDismiss { |
| | | quickSearchShouldHideOverlayOnClose = false |
| | | quickSearchOnlyOverlaySession = false |
| | | overlayOpenedByQuickSearchOnly = true |
| | | DispatchQueue.main.async { [weak self] in |
| | | self?.hideOverlay(force: true, discardWindow: true) |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func requestQuickSearch(source: String) { |
| | | isQuickSearchOpen = true |
| | | quickSearchShouldHideOverlayOnClose = quickSearchShouldHideOverlayOnClose |
| | | || source == QuickSearchOpenSource.globalHidden |
| | | quickSearchOnlyOverlaySession = quickSearchOnlyOverlaySession |
| | | || source == QuickSearchOpenSource.globalHidden |
| | | if source == QuickSearchOpenSource.globalHidden { |
| | | overlayOpenedByQuickSearchOnly = true |
| | | } |
| | | promoteOverlayToForegroundInput() |
| | | updateOverlayLevelForTextInput() |
| | | installQuickSearchExternalMouseMonitor() |
| | |
| | | } |
| | | |
| | | private func hideOverlay(force: Bool = false, discardWindow: Bool = false) { |
| | | Diagnostics.log("app.overlay.hideRequest", [ |
| | | "force": force, |
| | | "discardWindow": discardWindow, |
| | | "isQuickSearchOpen": isQuickSearchOpen, |
| | | "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession, |
| | | "quickSearchShouldHideOverlayOnClose": quickSearchShouldHideOverlayOnClose, |
| | | "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly |
| | | ]) |
| | | overlayController.hide(force: force, discardWindow: discardWindow) |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | private func handleHotkeyEvent(id: UInt32) { |
| | | Diagnostics.log("app.hotkey", [ |
| | | "id": id, |
| | | "isOverlayVisible": isOverlayVisible, |
| | | "isQuickSearchOpen": isQuickSearchOpen, |
| | | "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession, |
| | | "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly |
| | | ]) |
| | | if id == LauncherHotkeyKind.quickSearch.eventID { |
| | | showQuickSearchFromGlobalHotkey() |
| | | } else { |
| | |
| | | |
| | | private func showQuickSearchFromGlobalHotkey() { |
| | | let now = Date() |
| | | if overlayWindow?.isVisible == true, |
| | | isQuickSearchOpen || quickSearchOnlyOverlaySession || overlayOpenedByQuickSearchOnly { |
| | | lastQuickSearchHotkeyAt = now |
| | | if isQuickSearchOpen || quickSearchOnlyOverlaySession { |
| | | dismissQuickSearchFromKeyboard() |
| | | } else { |
| | | hideOverlay(force: true) |
| | | } |
| | | return |
| | | } |
| | | |
| | | if now.timeIntervalSince(lastQuickSearchHotkeyAt) < 0.28 { |
| | | promoteOverlayToForegroundInput() |
| | | updateOverlayLevelForTextInput() |
| | |
| | | } |
| | | lastQuickSearchHotkeyAt = now |
| | | |
| | | if overlayWindow?.isVisible == true, isQuickSearchOpen || quickSearchOnlyOverlaySession { |
| | | promoteOverlayToForegroundInput() |
| | | updateOverlayLevelForTextInput() |
| | | return |
| | | } |
| | | if overlayWindow?.isVisible == true { |
| | | refreshLauncherChromeState( |
| | | activate: !overlayAvoidsSpaceSwitch, |
| | |
| | | guard let self else { return } |
| | | let active = (notification.userInfo?["active"] as? Bool) ?? false |
| | | let hideOverlayOnClose = (notification.userInfo?["hideOverlayOnClose"] as? Bool) ?? false |
| | | let wasQuickSearchOnlyOverlay = self.quickSearchOnlyOverlaySession |
| | | || self.quickSearchShouldHideOverlayOnClose |
| | | || self.overlayOpenedByQuickSearchOnly |
| | | Diagnostics.log("app.quickSearch.visibilityChanged", [ |
| | | "active": active, |
| | | "hideOverlayOnClose": hideOverlayOnClose, |
| | | "isQuickSearchOpenBefore": self.isQuickSearchOpen, |
| | | "quickSearchOnlyOverlaySessionBefore": self.quickSearchOnlyOverlaySession, |
| | | "quickSearchShouldHideOverlayOnCloseBefore": self.quickSearchShouldHideOverlayOnClose, |
| | | "overlayOpenedByQuickSearchOnlyBefore": self.overlayOpenedByQuickSearchOnly, |
| | | "wasQuickSearchOnlyOverlay": wasQuickSearchOnlyOverlay |
| | | ]) |
| | | self.isQuickSearchOpen = active |
| | | if active, hideOverlayOnClose { |
| | | self.quickSearchShouldHideOverlayOnClose = true |
| | | self.quickSearchOnlyOverlaySession = true |
| | | self.overlayOpenedByQuickSearchOnly = true |
| | | } |
| | | if self.isQuickSearchOpen { |
| | | self.promoteOverlayToForegroundInput() |
| | |
| | | self.installQuickSearchExternalMouseMonitor() |
| | | } else { |
| | | self.removeQuickSearchExternalMouseMonitor() |
| | | if hideOverlayOnClose || self.quickSearchShouldHideOverlayOnClose { |
| | | if hideOverlayOnClose || wasQuickSearchOnlyOverlay { |
| | | self.quickSearchShouldHideOverlayOnClose = false |
| | | self.quickSearchOnlyOverlaySession = false |
| | | self.hideOverlay(force: true) |
| | | self.hideOverlay(force: true, discardWindow: wasQuickSearchOnlyOverlay) |
| | | } |
| | | } |
| | | } |
| | |
| | | ) { [weak self] _ in |
| | | DispatchQueue.main.async { |
| | | guard self?.isQuickSearchOpen == true else { return } |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchDismissRequested, |
| | | object: nil, |
| | | userInfo: ["source": QuickSearchDismissSource.mouse] |
| | | ) |
| | | } |
| | | } |
| | | } |
| | |
| | | ) { [weak self] event in |
| | | guard let self, self.isQuickSearchOpen else { return event } |
| | | if event.window === self.overlayWindow { |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchDismissRequested, |
| | | object: nil, |
| | | userInfo: ["source": QuickSearchDismissSource.mouse] |
| | | ) |
| | | return nil |
| | | } |
| | | return event |
| | |
| | | } |
| | | |
| | | @objc private func handleExternalActivationRequest(_ notification: Notification) { |
| | | let shouldShowOverlay = notification.userInfo?["showOverlay"] as? Bool ?? true |
| | | guard shouldShowOverlay else { return } |
| | | let shouldShowOverlay = notification.userInfo?["showOverlay"] as? Bool ?? false |
| | | guard shouldShowOverlay else { |
| | | suppressReopenUntil = Date().addingTimeInterval(1.0) |
| | | return |
| | | } |
| | | showOrFocusOverlay() |
| | | } |
| | | |
| | |
| | | updateOverlayLevelForTextInput() |
| | | quickSearchShouldHideOverlayOnClose = false |
| | | quickSearchOnlyOverlaySession = false |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchDismissRequested, |
| | | object: nil, |
| | | userInfo: ["source": QuickSearchDismissSource.programmatic] |
| | | ) |
| | | } |
| | | |
| | | @objc private func switchLanguage(_ sender: NSMenuItem) { |
| | |
| | | } |
| | | if hit == self { |
| | | if quickSearchSuppressesBackdropDismiss { |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | NotificationCenter.default.post( |
| | | name: .tagLauncherQuickSearchDismissRequested, |
| | | object: nil, |
| | | userInfo: ["source": QuickSearchDismissSource.backdrop] |
| | | ) |
| | | return |
| | | } |
| | | if suppressBackdropDismiss { |