| | |
| | | Settings { |
| | | PreferencesView() |
| | | } |
| | | .defaultSize(width: 880, height: 460) |
| | | .defaultSize(width: 1000, height: 480) |
| | | .commands { |
| | | CommandGroup(replacing: .systemServices) { } |
| | | CommandGroup(replacing: .appVisibility) { } |
| | |
| | | } |
| | | |
| | | // 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 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 statusItem: NSStatusItem? |
| | | private var overlayWindow: NSWindow? |
| | | private var overlayKeyMonitor: Any? |
| | | private var quickSearchLocalMouseMonitor: Any? |
| | | private var quickSearchExternalMouseMonitor: Any? |
| | |
| | | private var quickSearchHotkeyRef: EventHotKeyRef? |
| | | private var hotkeyEventHandlerInstalled = false |
| | | private var isQuickSearchOpen = false |
| | | private var quickSearchShouldHideOverlayOnClose = false |
| | | private var quickSearchOnlyOverlaySession = false |
| | | private var lastQuickSearchHotkeyAt = Date.distantPast |
| | | private var isModalInteractionActive = false |
| | | private var isInEditMode = false // Suppress auto-dismiss during editing |
| | | private var isEditingAppNote = false |
| | | 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 var overlayOpenedByQuickSearchOnly = false |
| | | |
| | | 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() |
| | | } |
| | | ) |
| | | }, |
| | | handleOverlayKeyEvent: { [weak self] event in |
| | | self?.handleOverlayKeyEvent(event) ?? false |
| | | }, |
| | | installOverlayKeyMonitor: { [weak self] in |
| | | self?.installOverlayKeyMonitor() |
| | | }, |
| | | removeOverlayKeyMonitor: { [weak self] in |
| | | 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 |
| | | }, |
| | | 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: { [weak self] in |
| | | self?.overlayOpenedByQuickSearchOnly = false |
| | | 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 { |
| | |
| | | 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) |
| | | } |
| | |
| | | NSApp.presentationOptions = desiredPresentation |
| | | } |
| | | |
| | | if activate && requiresForegroundOwnership && !shouldStayAccessoryForCurrentFullscreenSpace { |
| | | if activate && requiresForegroundOwnership |
| | | && !shouldStayAccessoryForCurrentFullscreenSpace |
| | | && !shouldStayAccessoryForHiddenDockChrome |
| | | && !shouldStayAccessoryForQuickOnlySearch { |
| | | let keyWindow = isSettingsVisible ? settingsWindow : (isOverlayVisible ? overlayWindow : nil) |
| | | claimLauncherForeground( |
| | | keyWindow: keyWindow, |
| | |
| | | } |
| | | |
| | | 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) |
| | | } |
| | | overlayOpenedByQuickSearchOnly = false |
| | | 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) |
| | | overlayOpenedByQuickSearchOnly = false |
| | | 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 |
| | | 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 |
| | | } |
| | | |
| | | 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() { |
| | | guard overlayKeyMonitor == nil else { return } |
| | | overlayKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in |
| | | guard let self else { return event } |
| | | guard self.shouldHandleOverlayKeyEvent(event) else { return event } |
| | | if event.keyCode == 53 { // Escape |
| | | if self.isQuickSearchOpen { |
| | | self.isQuickSearchOpen = false |
| | | self.removeQuickSearchExternalMouseMonitor() |
| | | self.updateOverlayLevelForTextInput() |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | return nil |
| | | } |
| | | self.hideOverlay(force: true) |
| | | return nil |
| | | } |
| | | if self.shouldOpenQuickSearch(for: event) { |
| | | self.requestQuickSearch(source: QuickSearchOpenSource.mainOverlay) |
| | | if self.handleOverlayKeyEvent(event) { |
| | | return nil |
| | | } |
| | | return event |
| | | } |
| | | } |
| | | |
| | | @discardableResult |
| | | private func handleOverlayKeyEvent(_ event: NSEvent) -> Bool { |
| | | guard event.type == .keyDown else { return false } |
| | | |
| | | if event.keyCode == UInt16(kVK_Escape), |
| | | isQuickSearchOpen || quickSearchOnlyOverlaySession { |
| | | return handleOverlayEscapeKey() |
| | | } |
| | | |
| | | guard shouldHandleOverlayKeyEvent(event) else { return false } |
| | | |
| | | if event.keyCode == UInt16(kVK_Escape) { |
| | | return handleOverlayEscapeKey() |
| | | } |
| | | if shouldOpenQuickSearch(for: event) { |
| | | requestQuickSearch(source: QuickSearchOpenSource.mainOverlay) |
| | | return true |
| | | } |
| | | return false |
| | | } |
| | | |
| | | private func handleOverlayEscapeKey() -> Bool { |
| | | if isQuickSearchOpen || quickSearchOnlyOverlaySession { |
| | | dismissQuickSearchFromKeyboard() |
| | | return true |
| | | } |
| | | guard !isSettingsVisible, |
| | | !isEditingAppNote, |
| | | !isModalInteractionActive |
| | | else { return false } |
| | | hideOverlay(force: true) |
| | | return true |
| | | } |
| | | |
| | | private func dismissQuickSearchFromKeyboard() { |
| | | let shouldHideOverlayAfterDismiss = quickSearchShouldHideOverlayOnClose || quickSearchOnlyOverlaySession |
| | | isQuickSearchOpen = false |
| | | removeQuickSearchExternalMouseMonitor() |
| | | updateOverlayLevelForTextInput() |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | 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 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 |
| | | } |
| | | Diagnostics.log("app.overlay.hideRequest", [ |
| | | "force": force, |
| | | "discardWindow": discardWindow, |
| | | "isQuickSearchOpen": isQuickSearchOpen, |
| | | "quickSearchOnlyOverlaySession": quickSearchOnlyOverlaySession, |
| | | "quickSearchShouldHideOverlayOnClose": quickSearchShouldHideOverlayOnClose, |
| | | "overlayOpenedByQuickSearchOnly": overlayOpenedByQuickSearchOnly |
| | | ]) |
| | | overlayController.hide(force: force, discardWindow: discardWindow) |
| | | } |
| | | |
| | | // MARK: - Global Hotkeys |
| | |
| | | } |
| | | |
| | | 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() |
| | | return |
| | | } |
| | | lastQuickSearchHotkeyAt = now |
| | | |
| | | if overlayWindow?.isVisible == true { |
| | | refreshLauncherChromeState( |
| | | activate: !overlayAvoidsSpaceSwitch, |
| | |
| | | requestQuickSearch(source: QuickSearchOpenSource.globalVisible) |
| | | return |
| | | } |
| | | isQuickSearchOpen = true |
| | | quickSearchShouldHideOverlayOnClose = true |
| | | quickSearchOnlyOverlaySession = true |
| | | showOverlay(initialQuickSearchSource: QuickSearchOpenSource.globalHidden) |
| | | } |
| | | |
| | |
| | | /// 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) |
| | | let settingsSize = NSSize(width: 1000, height: 480) |
| | | window.identifier = NSUserInterfaceItemIdentifier("TagLauncherPreferencesWindow") |
| | | window.minSize = settingsSize |
| | | window.maxSize = settingsSize |
| | |
| | | } |
| | | |
| | | 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. |
| | |
| | | overlayWindow.orderFrontRegardless() |
| | | return |
| | | } |
| | | if quickSearchOnlyOverlaySession && !UserDefaults.standard.bool(forKey: Self.showDockIconKey) { |
| | | refreshLauncherChromeState(activate: false) |
| | | guard let overlayWindow else { return } |
| | | NSApp.activate(ignoringOtherApps: true) |
| | | overlayWindow.makeKeyAndOrderFront(nil) |
| | | overlayWindow.orderFrontRegardless() |
| | | return |
| | | } |
| | | beginLauncherForegroundOwnership() |
| | | guard let overlayWindow else { return } |
| | | overlayWindow.makeKeyAndOrderFront(nil) |
| | |
| | | queue: .main |
| | | ) { [weak self] notification in |
| | | guard let self else { return } |
| | | self.isQuickSearchOpen = (notification.userInfo?["active"] as? Bool) ?? false |
| | | 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 || wasQuickSearchOnlyOverlay { |
| | | self.quickSearchShouldHideOverlayOnClose = false |
| | | self.quickSearchOnlyOverlaySession = false |
| | | self.hideOverlay(force: true, discardWindow: wasQuickSearchOnlyOverlay) |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | @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() |
| | | } |
| | | |
| | |
| | | return |
| | | } |
| | | |
| | | let settingsSize = NSSize(width: 880, height: 460) |
| | | let settingsSize = NSSize(width: 1000, height: 480) |
| | | let window = NSWindow( |
| | | contentRect: NSRect(origin: .zero, size: settingsSize), |
| | | styleMask: [.titled, .closable], |
| | |
| | | isQuickSearchOpen = false |
| | | removeQuickSearchExternalMouseMonitor() |
| | | updateOverlayLevelForTextInput() |
| | | quickSearchShouldHideOverlayOnClose = false |
| | | quickSearchOnlyOverlaySession = false |
| | | NotificationCenter.default.post(name: .tagLauncherQuickSearchDismissRequested, object: nil) |
| | | } |
| | | |