From 65b66f723294dd02174d30a67a05b88139f274ee Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 29 May 2026 05:12:04 +0800
Subject: [PATCH] Extract overlay window controller
---
Apptag/OverlayWindowController.swift | 330 +++++++++++++++++++++++++++
Apptag/ApptagApp.swift | 366 ++++++-----------------------
TODO.md | 11
3 files changed, 417 insertions(+), 290 deletions(-)
diff --git a/Apptag/ApptagApp.swift b/Apptag/ApptagApp.swift
index 3a5f1d7..7f0fabc 100644
--- a/Apptag/ApptagApp.swift
+++ b/Apptag/ApptagApp.swift
@@ -28,15 +28,6 @@
// 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
@@ -52,7 +43,6 @@
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?
@@ -67,18 +57,82 @@
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 {
@@ -487,7 +541,7 @@
}
func menuWillOpen(_ menu: NSMenu) {
- statusMenuScreenForNextOverlay = screenContainingCurrentPointer()
+ statusMenuScreenForNextOverlay = overlayController.screenContainingCurrentPointer()
?? statusItem?.button?.window?.screen
?? NSScreen.main
?? NSScreen.screens.first
@@ -774,37 +828,11 @@
}
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(
@@ -812,174 +840,11 @@
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() {
@@ -1050,60 +915,8 @@
}
}
- 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
@@ -1396,24 +1209,7 @@
}
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.
diff --git a/Apptag/OverlayWindowController.swift b/Apptag/OverlayWindowController.swift
new file mode 100644
index 0000000..03f74f2
--- /dev/null
+++ b/Apptag/OverlayWindowController.swift
@@ -0,0 +1,330 @@
+import AppKit
+import SwiftUI
+
+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 OverlayWindowController {
+ struct Dependencies {
+ let shouldStageAsAccessory: () -> Bool
+ let isSettingsVisible: () -> Bool
+ let settingsWindow: () -> NSWindow?
+ let canHideOverlay: () -> Bool
+ let currentOverlayLevel: () -> NSWindow.Level
+ let overlayLevel: (_ initialQuickSearchSource: String?) -> NSWindow.Level
+ let makeContentView: (_ initialQuickSearchSource: String?) -> NSView
+ let installOverlayKeyMonitor: () -> Void
+ let removeOverlayKeyMonitor: () -> Void
+ let removeQuickSearchMouseMonitor: () -> Void
+ let detachSettingsWindow: (NSWindow) -> Void
+ let prepareSettingsWindow: (NSWindow) -> Void
+ let refreshChromeState: (_ activate: Bool, _ avoidSpaceSwitch: Bool) -> Void
+ let onWillHide: () -> Void
+ let onDidHide: () -> Void
+ let onDidShow: () -> Void
+ }
+
+ private struct OverlayPlacementContext {
+ let screen: NSScreen
+ let frame: NSRect
+ }
+
+ private struct ForeignWindowFrame {
+ let owner: String
+ let frame: NSRect
+ }
+
+ private let dependencies: Dependencies
+
+ private(set) var window: NSWindow?
+ private(set) var generation = 0
+ var avoidsSpaceSwitch = false
+
+ var isVisible: Bool {
+ window?.isVisible == true
+ }
+
+ init(dependencies: Dependencies) {
+ self.dependencies = dependencies
+ }
+
+ func toggle(preferredScreen: NSScreen?) {
+ if window?.isVisible == true {
+ hide(force: true)
+ } else {
+ show(preferredScreen: preferredScreen)
+ }
+ }
+
+ func showOrFocus(preferredScreen: NSScreen? = nil) {
+ let placement = overlayPlacementContextForNextOverlay(preferredScreen: preferredScreen)
+ let shouldAvoidSpaceSwitch = placement.map { hasFullscreenWindowOnScreen($0.screen) } ?? false
+ if let window, window.isVisible {
+ avoidsSpaceSwitch = shouldAvoidSpaceSwitch
+ moveToCurrentPlacement(preferredScreen: preferredScreen)
+ window.level = dependencies.currentOverlayLevel()
+ dependencies.refreshChromeState(!shouldAvoidSpaceSwitch, shouldAvoidSpaceSwitch)
+ orderFront(window)
+ if let settingsWindow = dependencies.settingsWindow(), settingsWindow.isVisible {
+ dependencies.prepareSettingsWindow(settingsWindow)
+ }
+ return
+ }
+ show(preferredScreen: preferredScreen)
+ }
+
+ func show(
+ initialQuickSearchSource: String? = nil,
+ preferredScreen: NSScreen? = nil,
+ stagedForAllSpaces: Bool = false
+ ) {
+ guard let placement = overlayPlacementContextForNextOverlay(preferredScreen: preferredScreen) else { return }
+
+ let shouldAvoidSpaceSwitch = hasFullscreenWindowOnScreen(placement.screen)
+ let shouldStageAsAccessory = dependencies.shouldStageAsAccessory() || shouldAvoidSpaceSwitch
+
+ if !stagedForAllSpaces && shouldStageAsAccessory {
+ if NSApp.activationPolicy() != .accessory {
+ NSApp.setActivationPolicy(.accessory)
+ }
+ if NSApp.isActive {
+ NSApp.deactivate()
+ }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in
+ self?.show(
+ initialQuickSearchSource: initialQuickSearchSource,
+ preferredScreen: placement.screen,
+ stagedForAllSpaces: true
+ )
+ }
+ return
+ }
+
+ if window?.isVisible == true {
+ hide(force: true)
+ } else if let existingWindow = window {
+ existingWindow.orderOut(nil)
+ window = nil
+ }
+ avoidsSpaceSwitch = shouldAvoidSpaceSwitch
+
+ let newWindow = makeOverlayWindow(
+ on: placement.screen,
+ initialQuickSearchSource: initialQuickSearchSource
+ )
+ generation &+= 1
+ window = newWindow
+ dependencies.installOverlayKeyMonitor()
+ if shouldStageAsAccessory {
+ if NSApp.isActive {
+ NSApp.deactivate()
+ }
+ if NSApp.activationPolicy() != .accessory {
+ NSApp.setActivationPolicy(.accessory)
+ }
+ }
+
+ let targetLevel = dependencies.overlayLevel(initialQuickSearchSource)
+ newWindow.setFrame(placement.frame, display: true)
+ newWindow.level = targetLevel
+ orderFront(newWindow)
+
+ let placementFrame = placement.frame
+ let finishForegroundClaim: () -> Void = { [weak self, weak newWindow] in
+ guard let self, let newWindow, self.window === newWindow else { return }
+ self.dependencies.refreshChromeState(!shouldAvoidSpaceSwitch, shouldAvoidSpaceSwitch)
+ if newWindow.frame != placementFrame {
+ newWindow.setFrame(placementFrame, display: true)
+ self.orderFront(newWindow)
+ }
+ if let settingsWindow = self.dependencies.settingsWindow(), settingsWindow.isVisible {
+ self.dependencies.prepareSettingsWindow(settingsWindow)
+ }
+ self.dependencies.onDidShow()
+ }
+
+ if !dependencies.isSettingsVisible() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: finishForegroundClaim)
+ } else {
+ finishForegroundClaim()
+ }
+ }
+
+ func hide(force: Bool = false, discardWindow: Bool = false) {
+ guard force || dependencies.canHideOverlay() else { return }
+ generation &+= 1
+ dependencies.onWillHide()
+ if let settingsWindow = dependencies.settingsWindow(), settingsWindow.parent == window {
+ dependencies.detachSettingsWindow(settingsWindow)
+ }
+ window?.orderOut(nil)
+ dependencies.removeOverlayKeyMonitor()
+ dependencies.removeQuickSearchMouseMonitor()
+ avoidsSpaceSwitch = false
+ dependencies.refreshChromeState(false, false)
+ dependencies.onDidHide()
+ if discardWindow {
+ window = nil
+ }
+ }
+
+ func screenUnderMouse() -> NSScreen? {
+ overlayPlacementContextForNextOverlay(preferredScreen: nil)?.screen
+ }
+
+ func screenContainingCurrentPointer() -> NSScreen? {
+ let mousePoint = NSEvent.mouseLocation
+ return NSScreen.screens.first(where: {
+ NSMouseInRect(mousePoint, $0.frame, false)
+ })
+ }
+
+ private func moveToCurrentPlacement(preferredScreen: NSScreen?) {
+ guard let window,
+ let placement = overlayPlacementContextForNextOverlay(preferredScreen: preferredScreen),
+ window.frame != placement.frame
+ else { return }
+ window.setFrame(placement.frame, display: true)
+ }
+
+ private func makeOverlayWindow(on screen: NSScreen, initialQuickSearchSource: String?) -> NSWindow {
+ let panel = OverlayPanel(
+ contentRect: screen.frame,
+ styleMask: [.borderless, .fullSizeContentView, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.isFloatingPanel = true
+ panel.hidesOnDeactivate = false
+ 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 = dependencies.makeContentView(initialQuickSearchSource)
+ return panel
+ }
+
+ private func orderFront(_ window: NSWindow) {
+ window.makeKeyAndOrderFront(nil)
+ window.orderFrontRegardless()
+ }
+
+ 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 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
+ }
+}
diff --git a/TODO.md b/TODO.md
index 1eeeed5..ce466f2 100644
--- a/TODO.md
+++ b/TODO.md
@@ -13,11 +13,6 @@
## Todo
-- [2026-05-28] OverlayWindowController 收口。
- - 目标: 把 `AppDelegate` 里的 overlay 显示、隐藏、层级、屏幕选择拆出去。
- - 时机: Quick Search 窗口边界稳定后再做。
- - 验收: 全屏、Split View、Settings、Force Quit、文件面板、Dock 图标重复问题。
-
- [2026-05-28] AppLibraryController / 数据刷新管线整理。
- 目标: 把扫描、reconcile、Smart Start、annotate、Quick Search documents 更新从 `ContentView` 中抽离。
- 验收: 启动扫描、拖拽改标签、Quick Search 索引同步、新安装 App 后刷新。
@@ -28,6 +23,12 @@
## Done
+- [2026-05-29] OverlayWindowController 收口。
+ - 提交: 本次提交
+ - 结果: 新增 `OverlayWindowController`,将 overlay window 实例、generation、Space 避让、show/focus/hide 编排、panel 创建、屏幕选择、全屏/Split View 判定从 `AppDelegate` 中收口出去。
+ - 范围: AppDelegate 保留菜单、热键、Settings、Quick Search 和 app chrome 协调入口;窗口策略保持原行为。
+ - 验证: `bash build.sh`、`codesign --verify --deep --strict`、`Scripts/window_logic_qa.sh` 全部通过,覆盖全屏、Split View、Settings、Force Quit、文件面板、Dock 图标重复和屏幕跟随。
+
- [2026-05-29] Quick Search 独立面板化。
- 提交: 本次提交
- 结果: Quick Search 面板已从主 overlay SwiftUI 层拆出,改为独立 `NSPanel` 并作为 overlay child window 显示;搜索状态、输入框、结果列表、启动逻辑保持原路径。
--
Gitblit v1.9.3