From 6a49b26ff3ef989844673cb77d2700404242c424 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Tue, 09 Jun 2026 20:58:01 +0800
Subject: [PATCH] Release 0.0.48 verification candidate
---
C1.source/Resources/AppIcon.icns | 0
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png | 0
C3.tools/round1-app-shelf-fixture-qa.sh | 37
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png | 0
K2.项目管理/项目经理-经验与教训.md | 8
C3.tools/round1-app-column-alignment-fixture-qa.sh | 2
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | 35
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png | 0
C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift | 30
C3.tools/round1-three-zone-linkage-fixture-qa.sh | 289 ++++
C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift | 67
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png | 0
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 960 +++++++++++++---
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png | 0
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png | 0
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png | 0
C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift | 365 ++++++
C3.tools/round1-session-snapshot-qa.sh | 75 +
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png | 0
C3.tools/round1-keyboard-navigation-fixture-qa.sh | 6
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json | 2
C3.tools/round1-waterfall-fixture-qa.sh | 22
C1.source/Sources/Aligner/DebugWindowCloseService.swift | 25
C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift | 13
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 448 +++++++
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | 34
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png | 0
C1.source/Sources/Aligner/QuickSwitchCloseRequest.swift | 71 +
K2.项目管理/开发工作-经验与教训.md | 11
C3.tools/round1-close-fixture-qa.sh | 547 +++++++++
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png | 0
C1.source/Resources/Aligner-Info.plist | 4
C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png | 0
C3.tools/round1-mouse-interaction-fixture-qa.sh | 21
C3.tools/generate-app-icon.py | 269 ---
35 files changed, 2,827 insertions(+), 514 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index a772c18..6930b37 100644
--- a/C1.source/Resources/Aligner-Info.plist
+++ b/C1.source/Resources/Aligner-Info.plist
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
- <string>0.0.31</string>
+ <string>0.0.48</string>
<key>CFBundleVersion</key>
- <string>20260606.2302</string>
+ <string>20260609.2055</string>
<key>LSMinimumSystemVersion</key>
<string>26.0</string>
<key>NSHighResolutionCapable</key>
diff --git a/C1.source/Resources/AppIcon.icns b/C1.source/Resources/AppIcon.icns
index 5afe187..6865453 100644
--- a/C1.source/Resources/AppIcon.icns
+++ b/C1.source/Resources/AppIcon.icns
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
index 8815e3e..9a97ba8 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -62,7 +62,7 @@
}
],
"info": {
- "author": "xcode",
+ "author": "designer",
"version": 1
}
}
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png
index 4fec401..6feda06 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024_preview.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
index 0c0edfa..22b6b0b 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
index 9c66029..1f373b7 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
index c867cee..6499f86 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
index 2155d64..8c80484 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
index 9c66029..1f373b7 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
index a79eb5b..c8ce320 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
index 2155d64..8c80484 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
index 4d496ad..eee7d04 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
index a79eb5b..c8ce320 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
Binary files differ
diff --git a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
index 4fec401..6feda06 100644
--- a/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
+++ b/C1.source/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
Binary files differ
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 4218d77..3b968f2 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -4,6 +4,8 @@
@MainActor
final class AlignerApplicationDelegate: NSObject, NSApplicationDelegate {
+ private static let quickSwitchConfirmBeforeCloseDefaultsKey = "QuickSwitchConfirmBeforeClose"
+
private let permissionChecker = SystemPermissionChecker()
private let launchOptions = Round0DebugLaunchOptions.parse(arguments: ProcessInfo.processInfo.arguments)
private let round1PoCOptions = Round1PerformancePoCOptions.parse(arguments: ProcessInfo.processInfo.arguments)
@@ -352,7 +354,12 @@
let controller = QuickSwitchSessionController(
snapshotLoader: quickSwitchSnapshotLoader(),
windowActivationService: quickSwitchWindowActivationService(),
+ windowCloseService: quickSwitchWindowCloseService(),
disableScreenshotRefresh: round1QuickSwitchOptions.disableScreenshotRefresh,
+ closeConfirmationRequired: quickSwitchConfirmBeforeClose,
+ onCloseConfirmationDisabled: { [weak self] in
+ self?.setQuickSwitchConfirmBeforeClose(false)
+ },
debugHoveredAppGroupIndex: round1QuickSwitchOptions.debugHoveredAppGroupIndex,
debugOverlayWidth: round1QuickSwitchOptions.debugOverlayWidth,
debugKeySequence: round1QuickSwitchOptions.debugKeySequence,
@@ -421,7 +428,12 @@
let controller = QuickSwitchSessionController(
snapshotLoader: quickSwitchSnapshotLoader(),
windowActivationService: quickSwitchWindowActivationService(),
+ windowCloseService: quickSwitchWindowCloseService(),
disableScreenshotRefresh: round1QuickSwitchOptions.disableScreenshotRefresh,
+ closeConfirmationRequired: quickSwitchConfirmBeforeClose,
+ onCloseConfirmationDisabled: { [weak self] in
+ self?.setQuickSwitchConfirmBeforeClose(false)
+ },
debugHoveredAppGroupIndex: round1QuickSwitchOptions.debugHoveredAppGroupIndex,
debugOverlayWidth: round1QuickSwitchOptions.debugOverlayWidth,
debugKeySequence: round1QuickSwitchOptions.debugKeySequence,
@@ -509,7 +521,8 @@
return FixtureQuickSwitchSnapshotLoader(
appCount: fixtureAppCount,
windowsPerApp: round1QuickSwitchOptions.fixtureWindowsPerApp ?? 1,
- mode: .layout
+ mode: .layout,
+ usesMultipleDisplays: round1QuickSwitchOptions.fixtureMultiDisplay
)
}
@@ -529,6 +542,26 @@
return CGWindowAXWindowService(spaceIDsByWindowIDProvider: { _ in [:] })
}
+ private func quickSwitchWindowCloseService() -> any WindowCloseServiceProtocol {
+ if round1QuickSwitchOptions.debugWindowClose {
+ return DebugWindowCloseService()
+ }
+
+ return CGWindowAXWindowService(spaceIDsByWindowIDProvider: { _ in [:] })
+ }
+
+ private var quickSwitchConfirmBeforeClose: Bool {
+ let value = UserDefaults.standard.object(forKey: Self.quickSwitchConfirmBeforeCloseDefaultsKey)
+ return (value as? Bool) ?? true
+ }
+
+ private func setQuickSwitchConfirmBeforeClose(_ enabled: Bool) {
+ UserDefaults.standard.set(enabled, forKey: Self.quickSwitchConfirmBeforeCloseDefaultsKey)
+ DevelopmentDiagnostics.log("app.quickSwitch.closeConfirmationPreferenceChanged", [
+ "confirmBeforeClose": enabled
+ ])
+ }
+
@objc private func showPermissions() {
DevelopmentDiagnostics.log("app.permissions.show", [
"accessibility": permissionStatusString(accessibilityStatus()),
diff --git a/C1.source/Sources/Aligner/DebugWindowCloseService.swift b/C1.source/Sources/Aligner/DebugWindowCloseService.swift
new file mode 100644
index 0000000..924e6dd
--- /dev/null
+++ b/C1.source/Sources/Aligner/DebugWindowCloseService.swift
@@ -0,0 +1,25 @@
+import Foundation
+import AlignerCore
+
+final class DebugWindowCloseService: WindowCloseServiceProtocol {
+ private(set) var closedWindowIDs: [UInt32] = []
+ private(set) var closedAppProcessIdentifiers: [Int32] = []
+
+ func close(window: AlignerWindow) throws -> WindowCloseResult {
+ closedWindowIDs.append(window.id)
+ if ProcessInfo.processInfo.environment["ALIGNER_DEBUG_WINDOW_CLOSE_RESULT"] == "windowNotFound" {
+ return .windowNotFound
+ }
+ return .requested
+ }
+
+ func close(app: AlignerApp) throws -> WindowCloseResult {
+ if let processIdentifier = app.processIdentifier {
+ closedAppProcessIdentifiers.append(processIdentifier)
+ }
+ if ProcessInfo.processInfo.environment["ALIGNER_DEBUG_WINDOW_CLOSE_RESULT"] == "windowNotFound" {
+ return .windowNotFound
+ }
+ return .requested
+ }
+}
diff --git a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
index 799c87d..bba0912 100644
--- a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
+++ b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
@@ -1,10 +1,10 @@
import AppKit
-import ApplicationServices
+@preconcurrency import ApplicationServices
import CoreGraphics
import Foundation
import AlignerCore
-final class CGWindowAXWindowService: WindowServiceProtocol, WindowRestorationServiceProtocol, WindowActivationServiceProtocol {
+final class CGWindowAXWindowService: WindowServiceProtocol, WindowRestorationServiceProtocol, WindowActivationServiceProtocol, WindowCloseServiceProtocol {
weak var delegate: WindowServiceDelegate?
private let appCategorizer: any AppCategorizer
@@ -255,6 +255,286 @@
return didPrivatelyActivate || focusedBeforeActivate || focusedAfterActivate || raisedBeforeActivate || raisedAfterActivate
? .activated
: .appActivatedOnly
+ }
+
+ func close(window: AlignerWindow) throws -> WindowCloseResult {
+ DevelopmentDiagnostics.log("windowClose.window.start", [
+ "windowID": window.id,
+ "identifierSource": String(describing: window.identifierSource),
+ "titleHash": DevelopmentDiagnostics.stableFingerprint(window.title),
+ "titleLength": window.title.count,
+ "titleIsEmpty": window.title.isEmpty,
+ "appName": window.app.name,
+ "bundleIdentifier": window.app.bundleIdentifier,
+ "pid": window.app.processIdentifier
+ ])
+
+ let axMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer)
+ var matchedAXWindow = axWindow(for: window, in: axMetadata)
+ var finalAXMetadata = axMetadata
+
+ if matchedAXWindow == nil {
+ Thread.sleep(forTimeInterval: 0.12)
+ finalAXMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer)
+ matchedAXWindow = axWindow(for: window, in: finalAXMetadata, matchAttempt: "retry")
+ }
+
+ guard let matchedAXWindow else {
+ DevelopmentDiagnostics.log("windowClose.window.axMissing", [
+ "windowID": window.id,
+ "appName": window.app.name,
+ "pid": window.app.processIdentifier,
+ "candidateCount": finalAXMetadata.filter { $0.processIdentifier == window.app.processIdentifier }.count,
+ "matchingTitleCandidateCount": matchingAXTitleCandidateCount(for: window, in: finalAXMetadata),
+ "matchingSizeCandidateCount": matchingAXSizeCandidateCount(for: window, in: finalAXMetadata)
+ ])
+ return .windowNotFound
+ }
+
+ guard let closeButton = closeButton(for: matchedAXWindow) else {
+ Thread.sleep(forTimeInterval: 0.12)
+ let retryMetadata = AXWindowMetadataReader.metadata(appCategorizer: appCategorizer)
+ if let retryAXWindow = axWindow(for: window, in: retryMetadata, matchAttempt: "closeButtonRetry"),
+ let retryCloseButton = closeButton(for: retryAXWindow) {
+ return performCloseButtonPress(
+ retryCloseButton,
+ window: window,
+ processIdentifier: retryAXWindow.processIdentifier,
+ attempt: "closeButtonRetry"
+ )
+ }
+ DevelopmentDiagnostics.log("windowClose.window.closeButtonMissing", [
+ "windowID": window.id,
+ "pid": matchedAXWindow.processIdentifier,
+ "appName": window.app.name
+ ])
+ return .windowNotFound
+ }
+
+ return performCloseButtonPress(
+ closeButton,
+ window: window,
+ processIdentifier: matchedAXWindow.processIdentifier,
+ attempt: "initial"
+ )
+ }
+
+ private func closeButton(for axWindow: AXWindowMetadata) -> AXUIElement? {
+ var closeButtonValue: CFTypeRef?
+ let closeButtonLookupResult = AXUIElementCopyAttributeValue(
+ axWindow.element,
+ kAXCloseButtonAttribute as CFString,
+ &closeButtonValue
+ )
+ guard closeButtonLookupResult == .success,
+ let closeButton = closeButtonValue,
+ CFGetTypeID(closeButton) == AXUIElementGetTypeID()
+ else {
+ DevelopmentDiagnostics.log("windowClose.window.closeButtonLookupFailed", [
+ "pid": axWindow.processIdentifier,
+ "axError": String(describing: closeButtonLookupResult)
+ ])
+ return nil
+ }
+
+ return (closeButton as! AXUIElement)
+ }
+
+ private func performCloseButtonPress(
+ _ closeButton: AXUIElement,
+ window: AlignerWindow,
+ processIdentifier: Int32,
+ attempt: String
+ ) -> WindowCloseResult {
+ let closeResult = AXUIElementPerformAction(closeButton, kAXPressAction as CFString)
+ DevelopmentDiagnostics.log("windowClose.window.result", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "attempt": attempt,
+ "axError": String(describing: closeResult)
+ ])
+
+ switch closeResult {
+ case .success:
+ return .requested
+ case .actionUnsupported, .notImplemented:
+ return .unsupported
+ default:
+ return .failed(String(describing: closeResult))
+ }
+ }
+
+ private func axWindow(
+ for window: AlignerWindow,
+ in axMetadata: [AXWindowMetadata],
+ matchAttempt: String = "initial"
+ ) -> AXWindowMetadata? {
+ if let directMatch = axMetadata.first(where: { $0.windowID == window.id }) {
+ DevelopmentDiagnostics.log("windowClose.axMatch.directWindowID", [
+ "windowID": window.id,
+ "pid": window.app.processIdentifier,
+ "attempt": matchAttempt
+ ])
+ return directMatch
+ }
+
+ guard let processIdentifier = window.app.processIdentifier else {
+ return nil
+ }
+
+ if let cgFingerprint = cgWindowFingerprint(for: window) {
+ let fingerprintMatches = axMetadata.filter { metadata in
+ AXWindowFingerprint(metadata) == cgFingerprint
+ }
+ if fingerprintMatches.count == 1 {
+ DevelopmentDiagnostics.log("windowClose.axMatch.fingerprint", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "attempt": matchAttempt
+ ])
+ return fingerprintMatches[0]
+ }
+
+ if fingerprintMatches.count > 1 {
+ DevelopmentDiagnostics.log("windowClose.axMatch.fingerprintAmbiguous", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "candidateCount": fingerprintMatches.count
+ ])
+ }
+ }
+
+ let normalizedTitle = normalizedWindowTitle(window.title)
+ let titleMatches = axMetadata.filter { metadata in
+ metadata.processIdentifier == processIdentifier
+ && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle
+ }
+ if titleMatches.count == 1 {
+ DevelopmentDiagnostics.log("windowClose.axMatch.normalizedTitle", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "attempt": matchAttempt
+ ])
+ return titleMatches[0]
+ }
+
+ if window.app.bundleIdentifier == "com.apple.finder",
+ let finderMatch = finderFallbackAXWindow(
+ for: window,
+ processIdentifier: processIdentifier,
+ in: axMetadata
+ ) {
+ DevelopmentDiagnostics.log("windowClose.axMatch.finderFallback", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "attempt": matchAttempt
+ ])
+ return finderMatch
+ }
+
+ if titleMatches.count > 1 {
+ DevelopmentDiagnostics.log("windowClose.axMatch.titleAmbiguous", [
+ "windowID": window.id,
+ "pid": processIdentifier,
+ "candidateCount": titleMatches.count
+ ])
+ }
+
+ return nil
+ }
+
+ private func finderFallbackAXWindow(
+ for window: AlignerWindow,
+ processIdentifier: Int32,
+ in axMetadata: [AXWindowMetadata]
+ ) -> AXWindowMetadata? {
+ let normalizedTitle = normalizedWindowTitle(window.title)
+ guard !normalizedTitle.isEmpty else { return nil }
+
+ let finderCandidates = axMetadata.filter { metadata in
+ metadata.processIdentifier == processIdentifier
+ && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle
+ && metadata.subrole == .standard
+ }
+ return finderCandidates.count == 1 ? finderCandidates[0] : nil
+ }
+
+ private func cgWindowFingerprint(for window: AlignerWindow) -> AXWindowFingerprint? {
+ guard let rawWindows = CGWindowListCopyWindowInfo(
+ [.optionIncludingWindow],
+ CGWindowID(window.id)
+ ) as? [[String: Any]],
+ let rawWindow = rawWindows.first,
+ let ownerPID = int32Value(rawWindow[kCGWindowOwnerPID as String])
+ else {
+ return nil
+ }
+
+ return AXWindowFingerprint(
+ processIdentifier: ownerPID,
+ title: normalizedWindowTitle(title(rawWindow: rawWindow, axWindow: nil)),
+ size: bounds(rawWindow[kCGWindowBounds as String]).size
+ )
+ }
+
+ private func matchingAXTitleCandidateCount(
+ for window: AlignerWindow,
+ in axMetadata: [AXWindowMetadata]
+ ) -> Int {
+ guard let processIdentifier = window.app.processIdentifier else { return 0 }
+ let normalizedTitle = normalizedWindowTitle(window.title)
+ return axMetadata.filter { metadata in
+ metadata.processIdentifier == processIdentifier
+ && normalizedWindowTitle(metadata.title ?? "") == normalizedTitle
+ }.count
+ }
+
+ private func matchingAXSizeCandidateCount(
+ for window: AlignerWindow,
+ in axMetadata: [AXWindowMetadata]
+ ) -> Int {
+ guard let processIdentifier = window.app.processIdentifier,
+ let cgFingerprint = cgWindowFingerprint(for: window)
+ else {
+ return 0
+ }
+
+ return axMetadata.filter { metadata in
+ metadata.processIdentifier == processIdentifier
+ && Int(metadata.size.width.rounded()) == cgFingerprint.width
+ && Int(metadata.size.height.rounded()) == cgFingerprint.height
+ }.count
+ }
+
+ func close(app: AlignerApp) throws -> WindowCloseResult {
+ DevelopmentDiagnostics.log("windowClose.app.start", [
+ "appName": app.name,
+ "bundleIdentifier": app.bundleIdentifier,
+ "pid": app.processIdentifier
+ ])
+
+ guard let processIdentifier = app.processIdentifier else {
+ DevelopmentDiagnostics.log("windowClose.app.missingPID", [
+ "appName": app.name,
+ "bundleIdentifier": app.bundleIdentifier
+ ])
+ return .appNotFound
+ }
+ guard let runningApplication = NSRunningApplication(processIdentifier: processIdentifier) else {
+ DevelopmentDiagnostics.log("windowClose.app.missingApplication", [
+ "appName": app.name,
+ "pid": processIdentifier
+ ])
+ return .appNotFound
+ }
+
+ let requested = runningApplication.terminate()
+ DevelopmentDiagnostics.log("windowClose.app.result", [
+ "appName": app.name,
+ "pid": processIdentifier,
+ "requested": requested
+ ])
+ return requested ? .requested : .failed("terminateFailed")
}
private func activateViaPrivateWindowServerAPI(_ window: AlignerWindow) -> PrivateWindowActivationOutcome? {
@@ -639,7 +919,8 @@
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
if app.bundleIdentifier == "cc.ffitch.shottr" {
- return alpha > 0
+ return isOnscreen
+ && alpha > 0
&& trimmedTitle == "Shottr"
&& rawBounds.width > WindowEnumerationPolicy.minimumVisibleWidth
&& rawBounds.height > WindowEnumerationPolicy.minimumVisibleHeight
@@ -699,6 +980,7 @@
return "activationFailed"
}
}
+
}
private struct AXWindowMetadata {
@@ -794,12 +1076,8 @@
let app = app(from: application, appCategorizer: appCategorizer)
let appElement = AXUIElementCreateApplication(application.processIdentifier)
- var windowsValue: CFTypeRef?
- guard AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success,
- let windows = windowsValue as? [AXUIElement]
- else {
- continue
- }
+ let windows = windows(for: appElement)
+ guard !windows.isEmpty else { continue }
for (windowIndex, window) in windows.enumerated() {
let subrole = subrole(for: window)
@@ -818,6 +1096,38 @@
element: window
))
}
+ }
+
+ return result
+ }
+
+ private static func windows(for appElement: AXUIElement) -> [AXUIElement] {
+ var result: [AXUIElement] = []
+ var seen = Set<AXWindowElementKey>()
+
+ func append(_ window: AXUIElement) {
+ let key = AXWindowElementKey(window)
+ guard !seen.contains(key) else { return }
+ seen.insert(key)
+ result.append(window)
+ }
+
+ var windowsValue: CFTypeRef?
+ if AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsValue) == .success,
+ let windows = windowsValue as? [AXUIElement] {
+ windows.forEach(append)
+ }
+
+ for attribute in [kAXFocusedWindowAttribute, kAXMainWindowAttribute] {
+ var windowValue: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(appElement, attribute as CFString, &windowValue) == .success,
+ let window = windowValue,
+ CFGetTypeID(window) == AXUIElementGetTypeID()
+ else {
+ continue
+ }
+
+ append(window as! AXUIElement)
}
return result
@@ -925,6 +1235,39 @@
}
}
+private struct AXWindowElementKey: Hashable {
+ private let typeID: CFTypeID
+ private let pointer: UInt
+ private let windowID: UInt32?
+
+ init(_ element: AXUIElement) {
+ self.typeID = CFGetTypeID(element)
+ self.pointer = UInt(bitPattern: Unmanaged.passUnretained(element).toOpaque())
+ self.windowID = PrivateAXWindowIDReader.windowID(for: element)
+ }
+
+ static func == (lhs: AXWindowElementKey, rhs: AXWindowElementKey) -> Bool {
+ if let lhsWindowID = lhs.windowID,
+ let rhsWindowID = rhs.windowID {
+ return lhsWindowID == rhsWindowID
+ }
+
+ return lhs.typeID == rhs.typeID && lhs.pointer == rhs.pointer
+ }
+
+ func hash(into hasher: inout Hasher) {
+ if let windowID {
+ hasher.combine("windowID")
+ hasher.combine(windowID)
+ return
+ }
+
+ hasher.combine("pointer")
+ hasher.combine(typeID)
+ hasher.combine(pointer)
+ }
+}
+
private extension AlignerWindowSubrole {
var isExplicitlySupported: Bool {
switch self {
@@ -963,6 +1306,10 @@
value as? String
}
+private func normalizedWindowTitle(_ title: String) -> String {
+ title.trimmingCharacters(in: .whitespacesAndNewlines)
+}
+
private func boolValue(_ value: Any?) -> Bool {
switch value {
case let bool as Bool:
diff --git a/C1.source/Sources/Aligner/QuickSwitchCloseRequest.swift b/C1.source/Sources/Aligner/QuickSwitchCloseRequest.swift
new file mode 100644
index 0000000..aa2e5d3
--- /dev/null
+++ b/C1.source/Sources/Aligner/QuickSwitchCloseRequest.swift
@@ -0,0 +1,71 @@
+import Foundation
+import AlignerCore
+
+enum QuickSwitchCloseRequest {
+ case app(QuickSwitchAppShelfItemViewModel)
+ case window(QuickSwitchWindowCardViewModel)
+
+ var kindDescription: String {
+ switch self {
+ case .app:
+ return "app"
+ case .window:
+ return "window"
+ }
+ }
+
+ var appGroupIndex: Int {
+ switch self {
+ case .app(let item):
+ return item.appGroupIndex
+ case .window(let card):
+ return card.appGroupIndex
+ }
+ }
+
+ var windowID: UInt32? {
+ switch self {
+ case .app:
+ return nil
+ case .window(let card):
+ return card.window.id
+ }
+ }
+
+ var appName: String {
+ switch self {
+ case .app(let item):
+ return item.app.name
+ case .window(let card):
+ return card.window.app.name
+ }
+ }
+
+ var title: String {
+ switch self {
+ case .app(let item):
+ return item.app.name
+ case .window(let card):
+ let trimmedTitle = card.window.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmedTitle.isEmpty ? "Untitled Window" : trimmedTitle
+ }
+ }
+
+ var app: AlignerApp {
+ switch self {
+ case .app(let item):
+ return item.app
+ case .window(let card):
+ return card.window.app
+ }
+ }
+
+ var window: AlignerWindow? {
+ switch self {
+ case .app:
+ return nil
+ case .window(let card):
+ return card.window
+ }
+ }
+}
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 7c3bd30..d8451c2 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -22,11 +22,14 @@
private let waterfallLayer = CALayer()
private let waterfallContentLayer = CALayer()
private lazy var closeConfirmationLayers = makeCloseConfirmationLayers()
+ private let closeFeedbackLayer = CALayer()
+ private let closeFeedbackTextLayer = CATextLayer()
private let appIconProvider: any AppIconProviderProtocol = WorkspaceAppIconProvider()
private let skeletonThumbnailProvider = NativeSkeletonThumbnailProvider(size: NSSize(width: 160, height: 96))
private var screenshotSourcesByWindowID: [UInt32: ScreenshotResolutionSource] = [:]
private var screenshotSkippedReasonsByWindowID: [UInt32: String] = [:]
private var currentViewModel: QuickSwitchViewModel?
+ private var spaceLaneDisplayGroups: [SpaceLaneDisplayGroupLayers] = []
private var spaceLaneSegments: [SpaceLaneSegmentLayers] = []
private var spaceLaneContentWidth: CGFloat = 0
private var spaceLaneScrollOffset: CGFloat = 0
@@ -40,9 +43,14 @@
private var hoveredSpaceID: UInt64?
private var hoveredCloseTarget: CloseTarget?
private var pendingCloseTarget: CloseTarget?
+ private var pendingCloseRequest: QuickSwitchCloseRequest?
private var pendingCloseOptOut = false
private var closeConfirmationArrowEdge: CloseConfirmationArrowEdge?
private var closeConfirmationRequired = true
+ private var closeFeedback: CloseFeedback?
+ private var closeFeedbackHideTask: Task<Void, Never>?
+ private var hoverTarget: HoverTarget = .none
+ private var hoverTargetSource: HoverTargetSource = .event
private var hoveredSpaceLaneID: UInt64?
private var focusedSpaceID: UInt64?
private var debugHoveredAppGroupIndex: Int?
@@ -108,9 +116,92 @@
let cancelButtonTextLayer: CATextLayer
}
+ private struct CloseFeedback: Equatable, Sendable {
+ enum Kind: String, Sendable {
+ case progress
+ case failure
+ }
+
+ let kind: Kind
+ let message: String
+ let targetKind: String
+ let appGroupIndex: Int
+ let windowID: UInt32?
+ }
+
private enum CloseConfirmationArrowEdge: String {
case left
case right
+ }
+
+ private enum HoverTarget: Equatable {
+ case none
+ case space(UInt64)
+ case app(Int)
+ case window(appGroupIndex: Int, windowID: UInt32, spaceID: UInt64?)
+
+ var kindDescription: String {
+ switch self {
+ case .none:
+ return "none"
+ case .space:
+ return "space"
+ case .app:
+ return "app"
+ case .window:
+ return "window"
+ }
+ }
+
+ var appGroupIndex: Int? {
+ switch self {
+ case .app(let appGroupIndex):
+ return appGroupIndex
+ case .window(let appGroupIndex, _, _):
+ return appGroupIndex
+ case .none, .space:
+ return nil
+ }
+ }
+
+ var windowID: UInt32? {
+ switch self {
+ case .window(_, let windowID, _):
+ return windowID
+ case .none, .space, .app:
+ return nil
+ }
+ }
+
+ var spaceID: UInt64? {
+ switch self {
+ case .space(let spaceID):
+ return spaceID
+ case .window(_, _, let spaceID):
+ return spaceID
+ case .none, .app:
+ return nil
+ }
+ }
+
+ var spaceLaneID: UInt64? {
+ guard case .space(let spaceID) = self else { return nil }
+ return spaceID
+ }
+
+ var shouldAlignWaterfallWithAppShelf: Bool {
+ switch self {
+ case .app:
+ return true
+ case .none, .space, .window:
+ return false
+ }
+ }
+ }
+
+ private enum HoverTargetSource: String {
+ case event
+ case debug
}
private struct SpaceLaneSegmentLayers {
@@ -121,6 +212,12 @@
let appLayer: CATextLayer
let windowBlocks: [CALayer]
let fullscreenMarkerLayer: CALayer?
+ }
+
+ private struct SpaceLaneDisplayGroupLayers {
+ let display: QuickSwitchDisplayViewModel
+ let containerLayer: CALayer
+ let segments: [SpaceLaneSegmentLayers]
}
private struct AppShelfItemLayers {
@@ -184,11 +281,15 @@
static let columnWidth: CGFloat = 252
static let columnMinWidth: CGFloat = 220
static let columnMaxWidth: CGFloat = 292
- static let columnPadding: CGFloat = 12
+ static let contentHorizontalPadding: CGFloat = 0
+ static let columnHorizontalPadding: CGFloat = 0
+ static let columnVerticalPadding: CGFloat = 12
+ static let columnHeaderTextInset: CGFloat = 12
static let headerHeight: CGFloat = 34
static let cardHeight: CGFloat = 138
static let cardGap: CGFloat = 12
static let revealPadding: CGFloat = 24
+ static let horizontalRevealPadding: CGFloat = 0
}
private enum WaterfallHorizontalIntent {
@@ -200,6 +301,16 @@
static let tolerance: CGFloat = 1
static let animationDuration: CFTimeInterval = 0.18
static let animationTimingName = CAMediaTimingFunctionName.easeOut
+ }
+
+ private enum SpaceLaneMetrics {
+ static let horizontalPadding: CGFloat = 12
+ static let groupGap: CGFloat = 30
+ static let groupHorizontalPadding: CGFloat = 12
+ static let groupVerticalPadding: CGFloat = 10
+ static let segmentGap: CGFloat = 10
+ static let segmentMinWidth: CGFloat = 96
+ static let segmentMaxWidth: CGFloat = 150
}
override init(frame frameRect: NSRect) {
@@ -231,7 +342,7 @@
let trackingArea = NSTrackingArea(
rect: bounds,
- options: [.activeInKeyWindow, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect],
+ options: [.activeAlways, .mouseMoved, .mouseEnteredAndExited, .inVisibleRect],
owner: self,
userInfo: nil
)
@@ -268,6 +379,8 @@
}
override func scrollWheel(with event: NSEvent) {
+ refreshHoverTarget(at: event.locationInWindow)
+
let dominantDelta = abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY)
? event.scrollingDeltaX
: event.scrollingDeltaY
@@ -318,43 +431,13 @@
hoveredCloseTarget = closeTarget
needsLayout = true
}
-
- if let segment = spaceLaneSegment(at: event.locationInWindow) {
- hoverSpaceLaneSegment(segment)
- return
- }
-
- if let card = waterfallCard(at: event.locationInWindow) {
- hoveredSpaceLaneID = nil
- updateHoverState(
- appGroupIndex: card.item.appGroupIndex,
- windowID: card.item.window.id,
- spaceID: card.item.primarySpaceID
- )
- return
- }
-
- if let item = appShelfItem(at: event.locationInWindow) {
- hoveredSpaceLaneID = nil
- updateHoverState(
- appGroupIndex: item.item.appGroupIndex,
- windowID: nil,
- spaceID: nil,
- alignsWaterfallWithAppShelf: true
- )
- return
- }
-
- hoveredSpaceLaneID = nil
- hoveredCloseTarget = nil
- updateHoverState(appGroupIndex: nil, windowID: nil, spaceID: nil)
+ refreshHoverTarget(at: event.locationInWindow)
}
override func mouseExited(with event: NSEvent) {
guard debugHoveredAppGroupIndex == nil else { return }
- hoveredSpaceLaneID = nil
hoveredCloseTarget = nil
- updateHoverState(appGroupIndex: nil, windowID: nil, spaceID: nil)
+ setHoverTarget(.none)
}
override func mouseDown(with event: NSEvent) {
@@ -392,10 +475,8 @@
func setDebugHoveredAppGroupIndex(_ index: Int?) {
debugHoveredAppGroupIndex = index
+ setHoverTarget(.none, source: .debug)
hoveredAppGroupIndex = index
- hoveredWindowID = nil
- hoveredSpaceID = nil
- hoveredSpaceLaneID = nil
needsLayout = true
}
@@ -457,10 +538,47 @@
closeConfirmationRequired = required
if !required {
pendingCloseTarget = nil
+ pendingCloseRequest = nil
pendingCloseOptOut = false
closeConfirmationLayers.containerLayer.isHidden = true
closeConfirmationArrowEdge = nil
}
+ needsLayout = true
+ }
+
+ func showCloseProgress(for request: QuickSwitchCloseRequest) {
+ setCloseFeedback(
+ CloseFeedback(
+ kind: .progress,
+ message: "正在关闭...",
+ targetKind: request.kindDescription,
+ appGroupIndex: request.appGroupIndex,
+ windowID: request.windowID
+ ),
+ autoHideAfter: nil
+ )
+ }
+
+ func showCloseFailure(message: String, for request: QuickSwitchCloseRequest) {
+ setCloseFeedback(
+ CloseFeedback(
+ kind: .failure,
+ message: message,
+ targetKind: request.kindDescription,
+ appGroupIndex: request.appGroupIndex,
+ windowID: request.windowID
+ ),
+ autoHideAfter: 2.75
+ )
+ }
+
+ func clearCloseFeedback() {
+ closeFeedbackHideTask?.cancel()
+ closeFeedbackHideTask = nil
+ closeFeedback = nil
+ closeFeedbackLayer.isHidden = true
+ closeFeedbackLayer.opacity = 0
+ closeFeedbackTextLayer.string = ""
needsLayout = true
}
@@ -471,7 +589,9 @@
guard let card = waterfallCardsByWindowID()[windowID] else { return }
card.thumbnailLayer.contents = resolution.image
if case .realScreenshot = resolution.source {
- card.shineLayer.opacity = card.item.window.id == effectiveSelection?.windowID ? 1 : 0
+ let isSelected = card.item.window.id == effectiveSelection?.windowID
+ let isHovered = card.item.window.id == hoveredWindowID
+ card.shineLayer.opacity = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) ? 1 : 0
}
needsLayout = true
}
@@ -507,20 +627,16 @@
lastSpaceFocusedAppGroupIndex = nil
lastSpaceLaneClickSpaceID = nil
lastAppShelfScrollSyncedAppGroupIndex = nil
- hoveredWindowID = nil
- hoveredSpaceID = nil
+ focusedSpaceID = nil
hoveredCloseTarget = nil
pendingCloseTarget = nil
+ pendingCloseRequest = nil
pendingCloseOptOut = false
closeConfirmationLayers.containerLayer.isHidden = true
closeConfirmationArrowEdge = nil
- hoveredSpaceLaneID = nil
- focusedSpaceID = nil
- if debugHoveredAppGroupIndex == nil {
- hoveredAppGroupIndex = nil
- } else {
- hoveredAppGroupIndex = debugHoveredAppGroupIndex
- }
+ clearCloseFeedback()
+ setHoverTarget(.none)
+ hoveredAppGroupIndex = debugHoveredAppGroupIndex
if let currentSelection {
columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
}
@@ -557,13 +673,20 @@
"spaceLaneVisibleWidth": Double(laneLayer.bounds.width),
"spaceLaneMaxScrollOffset": Double(spaceLaneMaxScrollOffset),
"spaceLaneScrollable": spaceLaneMaxScrollOffset > 0,
+ "spaceLaneDisplayGroupCount": spaceLaneDisplayGroups.count,
+ "spaceLaneDisplayGroups": spaceLaneDisplayGroupReports(),
"spaceLaneSegmentCount": spaceLaneSegments.count,
"spaceLaneLabels": currentViewModel?.displays.flatMap { $0.spaces.map(\.label) } ?? [],
"spaceLaneSegments": spaceLaneSegmentReports(),
+ "hoverTargetKind": hoverTarget.kindDescription,
+ "hoverTargetSource": hoverTargetSource.rawValue,
"spaceLaneHoveredSpaceID": hoveredSpaceLaneID ?? NSNull(),
"spaceLaneFocusedSpaceID": focusedSpaceID ?? NSNull(),
"activeSpaceFocusID": activeSpaceFocusID ?? NSNull(),
"spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(),
+ "appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(),
+ "appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels,
+ "windowHoverAssociatedSpaceID": windowHoverAssociatedSpaceID ?? NSNull(),
"lastSpaceFocusedAppGroupIndex": lastSpaceFocusedAppGroupIndex ?? NSNull(),
"lastSpaceLaneClickSpaceID": lastSpaceLaneClickSpaceID ?? NSNull(),
"lastAppShelfScrollSyncedAppGroupIndex": lastAppShelfScrollSyncedAppGroupIndex ?? NSNull(),
@@ -627,6 +750,13 @@
"closeConfirmationVisible": !closeConfirmationLayers.containerLayer.isHidden,
"closeConfirmationFrame": dictionary(from: closeConfirmationLayers.containerLayer.frame),
"closeConfirmationArrowEdge": closeConfirmationArrowEdge?.rawValue ?? NSNull(),
+ "closeFeedbackVisible": closeFeedback != nil && !closeFeedbackLayer.isHidden,
+ "closeFeedbackKind": closeFeedback?.kind.rawValue ?? NSNull(),
+ "closeFeedbackMessage": closeFeedback?.message ?? NSNull(),
+ "closeFeedbackTargetKind": closeFeedback?.targetKind ?? NSNull(),
+ "closeFeedbackAppGroupIndex": closeFeedback?.appGroupIndex ?? NSNull(),
+ "closeFeedbackWindowID": closeFeedback?.windowID ?? NSNull(),
+ "closeFeedbackFrame": dictionary(from: closeFeedbackLayer.frame),
"mouseCommandsApplied": mouseCommandsApplied,
"lastMouseCommand": lastMouseCommand ?? NSNull(),
"lastClickedAppGroupIndex": lastClickedAppGroupIndex ?? NSNull(),
@@ -756,7 +886,7 @@
}
recordMouseCommand("hover-space:\(spaceID)")
if let segment = spaceLaneSegment(spaceID: spaceID) {
- hoverSpaceLaneSegment(segment)
+ hoverSpaceLaneSegment(segment, source: .debug)
}
case "click-space" where parts.count == 2:
guard let spaceID = UInt64(parts[1]) else {
@@ -774,7 +904,7 @@
}
recordMouseCommand("hover-card:\(appGroupIndex):\(windowIndex)")
if let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) {
- hoverWindowCard(card)
+ hoverWindowCard(card, source: .debug)
}
case "click-card" where parts.count == 3:
guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else {
@@ -826,13 +956,12 @@
}
private func hoverAppGroup(_ appGroupIndex: Int) {
- hoveredSpaceLaneID = nil
- hoveredCloseTarget = nil
updateHoverState(
appGroupIndex: appGroupIndex,
windowID: nil,
spaceID: nil,
- alignsWaterfallWithAppShelf: true
+ alignsWaterfallWithAppShelf: true,
+ source: .debug
)
}
@@ -847,19 +976,18 @@
beginCloseConfirmation(for: .app(appGroupIndex: appGroupIndex))
}
- private func hoverWindowCard(_ card: WaterfallCardLayers) {
- hoveredSpaceLaneID = nil
- hoveredCloseTarget = nil
+ private func hoverWindowCard(_ card: WaterfallCardLayers, source: HoverTargetSource = .event) {
updateHoverState(
appGroupIndex: card.item.appGroupIndex,
windowID: card.item.window.id,
- spaceID: card.item.primarySpaceID
+ spaceID: card.item.primarySpaceID,
+ source: source
)
}
private func hoverCardCloseButton(appGroupIndex: Int, windowIndex: Int) {
guard let card = waterfallCard(appGroupIndex: appGroupIndex, windowIndex: windowIndex) else { return }
- hoverWindowCard(card)
+ hoverWindowCard(card, source: .debug)
hoveredCloseTarget = .window(
appGroupIndex: appGroupIndex,
windowIndex: windowIndex,
@@ -880,8 +1008,8 @@
)
}
- private func hoverSpaceLaneSegment(_ segment: SpaceLaneSegmentLayers) {
- focusSpace(segment.space.id, persistent: false)
+ private func hoverSpaceLaneSegment(_ segment: SpaceLaneSegmentLayers, source: HoverTargetSource = .event) {
+ focusSpace(segment.space.id, persistent: false, source: source)
}
private func clickSpaceLaneSegment(_ segment: SpaceLaneSegmentLayers) {
@@ -902,11 +1030,8 @@
commitWindowCard(firstCard, trigger: "spaceLane")
}
- private func focusSpace(_ spaceID: UInt64, persistent: Bool) {
- hoveredAppGroupIndex = nil
- hoveredWindowID = nil
- hoveredSpaceID = spaceID
- hoveredSpaceLaneID = spaceID
+ private func focusSpace(_ spaceID: UInt64, persistent: Bool, source: HoverTargetSource = .event) {
+ setHoverTarget(.space(spaceID), source: source)
if persistent {
focusedSpaceID = spaceID
lastSpaceLaneClickSpaceID = spaceID
@@ -1035,24 +1160,67 @@
appGroupIndex: Int?,
windowID: UInt32?,
spaceID: UInt64?,
- alignsWaterfallWithAppShelf: Bool = false
+ alignsWaterfallWithAppShelf: Bool = false,
+ source: HoverTargetSource = .event
) {
- guard hoveredAppGroupIndex != appGroupIndex
- || hoveredWindowID != windowID
- || hoveredSpaceID != spaceID
- else {
- if alignsWaterfallWithAppShelf, let appGroupIndex {
- alignWaterfallColumnWithAppShelfIcon(appGroupIndex: appGroupIndex, animated: true)
- }
- return
+ let target: HoverTarget
+ if let appGroupIndex, let windowID {
+ target = .window(appGroupIndex: appGroupIndex, windowID: windowID, spaceID: spaceID)
+ } else if let appGroupIndex {
+ target = .app(appGroupIndex)
+ } else {
+ target = .none
}
- hoveredAppGroupIndex = appGroupIndex
- hoveredWindowID = windowID
- hoveredSpaceID = spaceID
- if alignsWaterfallWithAppShelf, let appGroupIndex {
+ setHoverTarget(target, source: source, alignsWaterfallWithAppShelf: alignsWaterfallWithAppShelf)
+ }
+
+ private func refreshHoverTarget(at windowLocation: CGPoint) {
+ guard debugHoveredAppGroupIndex == nil else { return }
+ let target = resolveHoverTarget(at: windowLocation)
+ setHoverTarget(target, alignsWaterfallWithAppShelf: target.shouldAlignWaterfallWithAppShelf)
+ }
+
+ private func resolveHoverTarget(at windowLocation: CGPoint) -> HoverTarget {
+ if let segment = spaceLaneSegment(at: windowLocation) {
+ return .space(segment.space.id)
+ }
+
+ if let card = waterfallCard(at: windowLocation) {
+ return .window(
+ appGroupIndex: card.item.appGroupIndex,
+ windowID: card.item.window.id,
+ spaceID: card.item.primarySpaceID
+ )
+ }
+
+ if let item = appShelfItem(at: windowLocation) {
+ return .app(item.item.appGroupIndex)
+ }
+
+ return .none
+ }
+
+ private func setHoverTarget(
+ _ target: HoverTarget,
+ source: HoverTargetSource = .event,
+ alignsWaterfallWithAppShelf: Bool = false
+ ) {
+ let previousTarget = hoverTarget
+ let previousSource = hoverTargetSource
+ hoverTarget = target
+ hoverTargetSource = source
+
+ hoveredAppGroupIndex = target.appGroupIndex
+ hoveredWindowID = target.windowID
+ hoveredSpaceID = target.spaceID
+ hoveredSpaceLaneID = target.spaceLaneID
+
+ if alignsWaterfallWithAppShelf, let appGroupIndex = target.appGroupIndex {
alignWaterfallColumnWithAppShelfIcon(appGroupIndex: appGroupIndex, animated: true)
}
+
+ guard previousTarget != target || previousSource != source else { return }
needsLayout = true
}
@@ -1165,7 +1333,7 @@
1,
waterfallLayer.bounds.height
- WaterfallMetrics.headerHeight
- - WaterfallMetrics.columnPadding * 2
+ - WaterfallMetrics.columnVerticalPadding * 2
)
let cardStride = WaterfallMetrics.cardHeight + WaterfallMetrics.cardGap
let cardStart = CGFloat(selection.windowIndex) * cardStride
@@ -1228,7 +1396,7 @@
currentOffset: waterfallScrollOffset,
visibleWidth: waterfallLayer.bounds.width,
maxOffset: waterfallMaxScrollOffset,
- inset: WaterfallMetrics.revealPadding
+ inset: WaterfallMetrics.horizontalRevealPadding
)
)
}
@@ -1427,6 +1595,24 @@
closeConfirmationLayers.containerLayer.isHidden = true
glassLayer.addSublayer(closeConfirmationLayers.containerLayer)
+
+ closeFeedbackLayer.isHidden = true
+ closeFeedbackLayer.opacity = 0
+ closeFeedbackLayer.cornerRadius = 11
+ closeFeedbackLayer.borderWidth = 1
+ closeFeedbackLayer.masksToBounds = false
+ closeFeedbackLayer.shadowColor = NSColor.black.cgColor
+ closeFeedbackLayer.shadowOpacity = 0.14
+ closeFeedbackLayer.shadowRadius = 16
+ closeFeedbackLayer.shadowOffset = CGSize(width: 0, height: -5)
+ closeFeedbackLayer.zPosition = 190
+ closeFeedbackTextLayer.contentsScale = backingScaleFactor
+ closeFeedbackTextLayer.font = NSFont.systemFont(ofSize: 13, weight: .medium)
+ closeFeedbackTextLayer.fontSize = 13
+ closeFeedbackTextLayer.alignmentMode = .center
+ closeFeedbackTextLayer.truncationMode = .end
+ closeFeedbackLayer.addSublayer(closeFeedbackTextLayer)
+ glassLayer.addSublayer(closeFeedbackLayer)
}
private func rebuildAppShelfItems() {
@@ -1434,22 +1620,15 @@
item.containerLayer.removeFromSuperlayer()
}
- let selectedAppGroupIndex = effectiveSelection?.appGroupIndex
appShelfItems = (currentViewModel?.appShelf ?? []).map { item in
let container = CALayer()
container.name = item.app.name
let background = CALayer()
background.cornerRadius = 10
- background.backgroundColor = (item.appGroupIndex == selectedAppGroupIndex
- ? NSColor.systemFill.withAlphaComponent(0.16)
- : NSColor.clear
- ).cgColor
- background.borderColor = (item.appGroupIndex == selectedAppGroupIndex
- ? NSColor.separatorColor.withAlphaComponent(0.36)
- : NSColor.clear
- ).cgColor
- background.borderWidth = item.appGroupIndex == selectedAppGroupIndex ? 1 : 0
+ background.backgroundColor = NSColor.clear.cgColor
+ background.borderColor = NSColor.clear.cgColor
+ background.borderWidth = 0
let icon = CALayer()
icon.contentsGravity = .resizeAspect
@@ -1523,7 +1702,8 @@
string: column.app.name,
fontSize: 13,
weight: .semibold,
- color: .labelColor
+ color: .labelColor,
+ alignment: .center
)
let count = makeTextLayer(
string: "\(column.windows.count)",
@@ -1632,66 +1812,93 @@
}
private func rebuildSpaceLaneSegments() {
- for segment in spaceLaneSegments {
- segment.containerLayer.removeFromSuperlayer()
+ for group in spaceLaneDisplayGroups {
+ group.containerLayer.removeFromSuperlayer()
}
- let spaces = currentViewModel?.displays.flatMap(\.spaces) ?? []
- spaceLaneSegments = spaces.map { space in
- let container = CALayer()
- container.name = space.label
- container.cornerRadius = 10
- container.borderWidth = 1
- container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor
- container.backgroundColor = spaceColor(for: space).cgColor
- container.shadowColor = NSColor.black.cgColor
+ var groups: [SpaceLaneDisplayGroupLayers] = []
+ var allSegments: [SpaceLaneSegmentLayers] = []
- let labelLayer = makeTextLayer(
- string: space.label,
- fontSize: 15,
- weight: .semibold,
- color: .labelColor
- )
- let countLayer = makeTextLayer(
- string: "\(space.windowCount)",
- fontSize: 12,
- weight: .medium,
- color: .secondaryLabelColor,
- alignment: .center
- )
- let appLayer = makeTextLayer(
- string: appIdentifierText(for: space),
- fontSize: 12,
- weight: space.type == .fullscreen ? .medium : .regular,
- color: space.type == .fullscreen ? .labelColor : .secondaryLabelColor,
- alignment: space.type == .fullscreen ? .center : .left
- )
- let windowBlocks = space.type == .fullscreen
- ? []
- : makeWindowBlocks(count: min(space.windowCount, 5), space: space)
- let fullscreenMarkerLayer = makeFullscreenMarkerLayer(for: space)
+ for display in currentViewModel?.displays ?? [] {
+ let groupContainer = CALayer()
+ groupContainer.name = display.displayLabel
+ groupContainer.cornerRadius = 16
+ groupContainer.masksToBounds = false
+ groupContainer.borderWidth = 1
+ groupContainer.borderColor = NSColor.separatorColor.withAlphaComponent(0.26).cgColor
+ groupContainer.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.28).cgColor
+ groupContainer.shadowColor = NSColor.black.cgColor
+ groupContainer.shadowOpacity = 0
+ groupContainer.shadowRadius = 0
+ groupContainer.shadowOffset = .zero
- container.addSublayer(labelLayer)
- container.addSublayer(countLayer)
- container.addSublayer(appLayer)
- if let fullscreenMarkerLayer {
- container.addSublayer(fullscreenMarkerLayer)
+ let segments = display.spaces.map { space in
+ let container = CALayer()
+ container.name = space.label
+ container.cornerRadius = 10
+ container.borderWidth = 1
+ container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor
+ container.backgroundColor = spaceColor(for: space).cgColor
+ container.shadowColor = NSColor.black.cgColor
+
+ let labelLayer = makeTextLayer(
+ string: space.label,
+ fontSize: 15,
+ weight: .semibold,
+ color: .labelColor
+ )
+ let countLayer = makeTextLayer(
+ string: "\(space.windowCount)",
+ fontSize: 12,
+ weight: .medium,
+ color: .secondaryLabelColor,
+ alignment: .center
+ )
+ let appLayer = makeTextLayer(
+ string: appIdentifierText(for: space),
+ fontSize: 12,
+ weight: space.type == .fullscreen ? .medium : .regular,
+ color: space.type == .fullscreen ? .labelColor : .secondaryLabelColor,
+ alignment: space.type == .fullscreen ? .center : .left
+ )
+ let windowBlocks = space.type == .fullscreen
+ ? []
+ : makeWindowBlocks(count: min(space.windowCount, 5), space: space)
+ let fullscreenMarkerLayer = makeFullscreenMarkerLayer(for: space)
+
+ container.addSublayer(labelLayer)
+ container.addSublayer(countLayer)
+ container.addSublayer(appLayer)
+ if let fullscreenMarkerLayer {
+ container.addSublayer(fullscreenMarkerLayer)
+ }
+ for block in windowBlocks {
+ container.addSublayer(block)
+ }
+ groupContainer.addSublayer(container)
+
+ return SpaceLaneSegmentLayers(
+ space: space,
+ containerLayer: container,
+ labelLayer: labelLayer,
+ countLayer: countLayer,
+ appLayer: appLayer,
+ windowBlocks: windowBlocks,
+ fullscreenMarkerLayer: fullscreenMarkerLayer
+ )
}
- for block in windowBlocks {
- container.addSublayer(block)
- }
- spaceLaneContentLayer.addSublayer(container)
- return SpaceLaneSegmentLayers(
- space: space,
- containerLayer: container,
- labelLayer: labelLayer,
- countLayer: countLayer,
- appLayer: appLayer,
- windowBlocks: windowBlocks,
- fullscreenMarkerLayer: fullscreenMarkerLayer
- )
+ spaceLaneContentLayer.addSublayer(groupContainer)
+ groups.append(SpaceLaneDisplayGroupLayers(
+ display: display,
+ containerLayer: groupContainer,
+ segments: segments
+ ))
+ allSegments.append(contentsOf: segments)
}
+
+ spaceLaneDisplayGroups = groups
+ spaceLaneSegments = allSegments
}
private func layoutLayers() {
@@ -1740,6 +1947,7 @@
layoutAppShelfItems()
layoutWaterfallColumns()
layoutCloseConfirmation()
+ layoutCloseFeedback()
CATransaction.commit()
}
@@ -1823,15 +2031,33 @@
}
private func layoutSpaceLaneSegments() {
- guard !spaceLaneSegments.isEmpty else { return }
+ guard !spaceLaneDisplayGroups.isEmpty, !spaceLaneSegments.isEmpty else { return }
- let gap: CGFloat = 10
- let segmentHeight = max(1, laneLayer.bounds.height - 20)
- let availableWidth = laneLayer.bounds.width - 24 - CGFloat(spaceLaneSegments.count - 1) * gap
- let segmentWidth = max(96, min(150, availableWidth / CGFloat(spaceLaneSegments.count)))
+ let groupHeight = max(1, laneLayer.bounds.height - 16)
+ let segmentHeight = max(1, groupHeight - SpaceLaneMetrics.groupVerticalPadding * 2)
+ let groupGapWidth = CGFloat(max(0, spaceLaneDisplayGroups.count - 1)) * SpaceLaneMetrics.groupGap
+ let segmentGapWidth = spaceLaneDisplayGroups.reduce(CGFloat(0)) { total, group in
+ total + CGFloat(max(0, group.segments.count - 1)) * SpaceLaneMetrics.segmentGap
+ }
+ let groupPaddingWidth = CGFloat(spaceLaneDisplayGroups.count) * SpaceLaneMetrics.groupHorizontalPadding * 2
+ let availableWidth = laneLayer.bounds.width
+ - SpaceLaneMetrics.horizontalPadding * 2
+ - groupGapWidth
+ - segmentGapWidth
+ - groupPaddingWidth
+ let segmentWidth = max(
+ SpaceLaneMetrics.segmentMinWidth,
+ min(SpaceLaneMetrics.segmentMaxWidth, availableWidth / CGFloat(spaceLaneSegments.count))
+ )
+ let groupWidths = spaceLaneDisplayGroups.map { group in
+ SpaceLaneMetrics.groupHorizontalPadding * 2
+ + CGFloat(group.segments.count) * segmentWidth
+ + CGFloat(max(0, group.segments.count - 1)) * SpaceLaneMetrics.segmentGap
+ }
+ let segmentGroupWidth = groupWidths.reduce(0, +) + groupGapWidth
spaceLaneContentWidth = max(
laneLayer.bounds.width,
- 24 + CGFloat(spaceLaneSegments.count) * segmentWidth + CGFloat(spaceLaneSegments.count - 1) * gap
+ SpaceLaneMetrics.horizontalPadding * 2 + segmentGroupWidth
)
spaceLaneScrollOffset = clampedSpaceLaneOffset(spaceLaneScrollOffset)
spaceLaneContentLayer.frame = CGRect(
@@ -1841,13 +2067,35 @@
height: laneLayer.bounds.height
)
- var x: CGFloat = 12
+ var x = max(
+ SpaceLaneMetrics.horizontalPadding,
+ (spaceLaneContentWidth - segmentGroupWidth) / 2
+ )
- for segment in spaceLaneSegments {
- segment.containerLayer.frame = CGRect(x: x, y: 10, width: segmentWidth, height: segmentHeight)
- applySpaceLaneVisualState(segment)
- layoutSpaceLaneSegmentContents(segment)
- x += segmentWidth + gap
+ for (index, group) in spaceLaneDisplayGroups.enumerated() {
+ let groupWidth = groupWidths[index]
+ group.containerLayer.frame = CGRect(
+ x: x,
+ y: 8,
+ width: groupWidth,
+ height: groupHeight
+ )
+ applySpaceLaneDisplayGroupVisualState(group)
+
+ var segmentX = SpaceLaneMetrics.groupHorizontalPadding
+ for segment in group.segments {
+ segment.containerLayer.frame = CGRect(
+ x: segmentX,
+ y: SpaceLaneMetrics.groupVerticalPadding,
+ width: segmentWidth,
+ height: segmentHeight
+ )
+ applySpaceLaneVisualState(segment)
+ layoutSpaceLaneSegmentContents(segment)
+ segmentX += segmentWidth + SpaceLaneMetrics.segmentGap
+ }
+
+ x += groupWidth + SpaceLaneMetrics.groupGap
}
}
@@ -1859,29 +2107,74 @@
min(max(0, offset), spaceLaneMaxScrollOffset)
}
+ private func applySpaceLaneDisplayGroupVisualState(_ group: SpaceLaneDisplayGroupLayers) {
+ let hasActiveSegment = group.segments.contains { segment in
+ segment.space.id == activeSpaceFocusID
+ || appHoverAssociatedSpaceIDs.contains(segment.space.id)
+ || segment.space.id == windowHoverAssociatedSpaceID
+ }
+ let hasCurrentSegment = group.display.spaces.contains { $0.isCurrent }
+ let hasWindows = group.display.spaces.contains { $0.windowCount > 0 }
+ let background = hasActiveSegment
+ ? accentTintedWindowBackground(fraction: 0.035, alpha: 0.62)
+ : NSColor.windowBackgroundColor.withAlphaComponent(hasWindows ? 0.44 : 0.30)
+
+ group.containerLayer.backgroundColor = background.cgColor
+ group.containerLayer.borderColor = (hasActiveSegment
+ ? spaceLaneOccupiedColor.withAlphaComponent(0.36)
+ : hasCurrentSegment
+ ? NSColor.labelColor.withAlphaComponent(0.22)
+ : NSColor.separatorColor.withAlphaComponent(0.24)
+ ).cgColor
+ group.containerLayer.borderWidth = 1
+ group.containerLayer.shadowOpacity = hasActiveSegment ? 0.12 : 0
+ group.containerLayer.shadowRadius = hasActiveSegment ? 14 : 0
+ group.containerLayer.shadowOffset = CGSize(width: 0, height: -3)
+ group.containerLayer.zPosition = hasActiveSegment ? 8 : hasCurrentSegment ? 4 : 0
+ group.containerLayer.shadowPath = CGPath(
+ roundedRect: group.containerLayer.bounds,
+ cornerWidth: group.containerLayer.cornerRadius,
+ cornerHeight: group.containerLayer.cornerRadius,
+ transform: nil
+ )
+ }
+
private func applySpaceLaneVisualState(_ segment: SpaceLaneSegmentLayers) {
- let isHovered = segment.space.id == hoveredSpaceID
+ let isHovered = segment.space.id == activeSpaceFocusID
let isFocused = segment.space.id == activeSpaceFocusID
let isCurrent = segment.space.isCurrent
+ let hasWindows = segment.space.windowCount > 0
+ let isAppAssociated = appHoverAssociatedSpaceIDs.contains(segment.space.id)
+ let isWindowAssociated = segment.space.id == windowHoverAssociatedSpaceID
let isActive = isHovered || isFocused
+ let isAssociated = (isAppAssociated || isWindowAssociated) && !isActive
- segment.containerLayer.borderWidth = (isCurrent || isActive) ? 1.5 : 1
+ segment.containerLayer.borderWidth = (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1
segment.containerLayer.borderColor = (isActive
- ? NSColor.controlAccentColor.withAlphaComponent(0.54)
- : isCurrent
- ? NSColor.labelColor.withAlphaComponent(0.34)
+ ? spaceLaneOccupiedColor.withAlphaComponent(0.62)
+ : isAssociated
+ ? spaceLaneOccupiedColor.withAlphaComponent(0.50)
+ : hasWindows
+ ? spaceLaneOccupiedColor.withAlphaComponent(isCurrent ? 0.54 : 0.44)
+ : isCurrent
+ ? NSColor.labelColor.withAlphaComponent(0.34)
: NSColor.separatorColor.withAlphaComponent(0.34)
).cgColor
segment.containerLayer.backgroundColor = (isActive
? accentTintedWindowBackground(fraction: 0.10, alpha: 0.94)
- : spaceColor(for: segment.space)
+ : isAssociated
+ ? accentTintedWindowBackground(fraction: 0.07, alpha: 0.90)
+ : spaceColor(for: segment.space)
).cgColor
- segment.containerLayer.shadowOpacity = isActive ? 0.26 : isCurrent ? 0.14 : 0
- segment.containerLayer.shadowRadius = isActive ? 24 : isCurrent ? 12 : 0
- segment.containerLayer.shadowOffset = CGSize(width: 0, height: isActive ? -6 : isCurrent ? -2.5 : 0)
- segment.containerLayer.zPosition = isActive ? 30 : isCurrent ? 10 : 0
- segment.containerLayer.transform = isActive
- ? CATransform3DMakeScale(1.03, 1.03, 1)
+ segment.containerLayer.shadowOpacity = isActive ? 0.26 : isAssociated ? 0.18 : isCurrent ? 0.14 : 0
+ segment.containerLayer.shadowRadius = isActive ? 24 : isAssociated ? 18 : isCurrent ? 12 : 0
+ segment.containerLayer.shadowOffset = CGSize(
+ width: 0,
+ height: isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0
+ )
+ segment.containerLayer.zPosition = isActive ? 30 : isAssociated ? 18 : isCurrent ? 10 : 0
+ segment.containerLayer.transform = (isActive || isAssociated)
+ ? CATransform3DMakeScale(isActive ? 1.03 : 1.015, isActive ? 1.03 : 1.015, 1)
: CATransform3DIdentity
segment.containerLayer.shadowPath = CGPath(
roundedRect: segment.containerLayer.bounds,
@@ -1944,9 +2237,17 @@
y: locationInView.y - glassLayer.frame.minY - laneLayer.frame.minY - spaceLaneContentLayer.frame.minY
)
- return spaceLaneSegments.first { segment in
- segment.containerLayer.frame.contains(locationInSpaceLaneContent)
+ for group in spaceLaneDisplayGroups where group.containerLayer.frame.contains(locationInSpaceLaneContent) {
+ let locationInGroup = CGPoint(
+ x: locationInSpaceLaneContent.x - group.containerLayer.frame.minX,
+ y: locationInSpaceLaneContent.y - group.containerLayer.frame.minY
+ )
+ if let segment = group.segments.first(where: { $0.containerLayer.frame.contains(locationInGroup) }) {
+ return segment
+ }
}
+
+ return nil
}
private func spaceLaneSegment(spaceID: UInt64) -> SpaceLaneSegmentLayers? {
@@ -2069,8 +2370,8 @@
private func layoutAppShelfItemContents(_ item: AppShelfItemLayers, iconSize: CGFloat) {
let bounds = item.containerLayer.bounds
let isSelected = item.item.appGroupIndex == effectiveSelection?.appGroupIndex
- let showsSelectedVisual = shouldShowSelectedVisual(isSelected)
let isHovered = item.item.appGroupIndex == effectiveHoveredAppGroupIndex
+ let showsSelectedVisual = shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered)
let isSpaceFocused = spaceFocusWindowCount(for: item.item.appGroupIndex) > 0
let visualIconSize = appShelfVisualIconSize(
forBaseIconSize: iconSize,
@@ -2174,15 +2475,52 @@
}
private var activeSpaceFocusID: UInt64? {
- hoveredSpaceLaneID
+ guard case .space(let spaceID) = hoverTarget else { return nil }
+
+ if hoverTargetSource == .debug {
+ return spaceID
+ }
+
+ guard let window else { return nil }
+ let currentMouseLocation = window.mouseLocationOutsideOfEventStream
+ guard let segment = spaceLaneSegment(at: currentMouseLocation),
+ segment.space.id == spaceID
+ else {
+ return nil
+ }
+ return spaceID
+ }
+
+ private var appHoverAssociatedSpaceIDs: Set<UInt64> {
+ guard case .app(let appGroupIndex) = hoverTarget else { return [] }
+ return Set(
+ waterfallColumns
+ .first { $0.column.appGroupIndex == appGroupIndex }?
+ .cards
+ .compactMap(\.item.primarySpaceID) ?? []
+ )
+ }
+
+ private var appHoverAssociatedSpaceLabels: [String] {
+ guard !appHoverAssociatedSpaceIDs.isEmpty else { return [] }
+ return currentViewModel?.displays
+ .flatMap(\.spaces)
+ .filter { appHoverAssociatedSpaceIDs.contains($0.id) }
+ .map(\.label)
+ .sorted() ?? []
+ }
+
+ private var windowHoverAssociatedSpaceID: UInt64? {
+ guard case .window(_, _, let spaceID) = hoverTarget else { return nil }
+ return spaceID
}
private var isSpaceAssociationActive: Bool {
activeSpaceFocusID != nil
}
- private func shouldShowSelectedVisual(_ isSelected: Bool) -> Bool {
- isSelected && !isSpaceAssociationActive
+ private func shouldShowSelectedVisual(isSelected: Bool, isHovered: Bool) -> Bool {
+ isSelected && isHovered && !isSpaceAssociationActive
}
private func spaceFocusedAppGroupIndexes() -> [Int] {
@@ -2319,7 +2657,7 @@
}
let columnWidth = waterfallColumnWidth()
- let totalWidth = WaterfallMetrics.columnPadding * 2
+ let totalWidth = WaterfallMetrics.contentHorizontalPadding * 2
+ CGFloat(waterfallColumns.count) * columnWidth
+ CGFloat(max(0, waterfallColumns.count - 1)) * WaterfallMetrics.gap
waterfallContentWidth = max(waterfallLayer.bounds.width, totalWidth)
@@ -2331,7 +2669,7 @@
height: waterfallLayer.bounds.height
)
- var x = WaterfallMetrics.columnPadding
+ var x = WaterfallMetrics.contentHorizontalPadding
for column in waterfallColumns {
column.containerLayer.frame = CGRect(
x: x,
@@ -2347,6 +2685,7 @@
private func layoutWaterfallColumn(_ column: WaterfallColumnLayers) {
let bounds = column.containerLayer.bounds
let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0
+ let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex
column.containerLayer.borderColor = (isSpaceFocused
? NSColor.separatorColor.withAlphaComponent(0.38)
: NSColor.separatorColor.withAlphaComponent(0.24)
@@ -2363,11 +2702,15 @@
: NSColor.controlBackgroundColor.withAlphaComponent(0.52)
).cgColor
column.appNameLayer.frame = CGRect(
- x: WaterfallMetrics.columnPadding,
+ x: WaterfallMetrics.columnHeaderTextInset,
y: bounds.height - 24,
- width: max(1, bounds.width - 64),
+ width: max(1, bounds.width - WaterfallMetrics.columnHeaderTextInset * 2),
height: 16
)
+ column.appNameLayer.foregroundColor = (isHovered
+ ? spaceLaneOccupiedColor
+ : NSColor.labelColor
+ ).cgColor
column.countLayer.frame = CGRect(
x: bounds.width - 48,
y: bounds.height - 24,
@@ -2382,10 +2725,10 @@
)
waterfallColumnScrollOffsets[appGroupIndex] = verticalOffset
- let cardWidth = max(1, bounds.width - WaterfallMetrics.columnPadding * 2)
+ let cardWidth = max(1, bounds.width - WaterfallMetrics.columnHorizontalPadding * 2)
let firstCardTop = bounds.height
- WaterfallMetrics.headerHeight
- - WaterfallMetrics.columnPadding
+ - WaterfallMetrics.columnVerticalPadding
+ verticalOffset
for card in column.cards {
@@ -2394,7 +2737,7 @@
- WaterfallMetrics.cardHeight
- CGFloat(cardIndex) * (WaterfallMetrics.cardHeight + WaterfallMetrics.cardGap)
card.containerLayer.frame = CGRect(
- x: WaterfallMetrics.columnPadding,
+ x: WaterfallMetrics.columnHorizontalPadding,
y: y,
width: cardWidth,
height: WaterfallMetrics.cardHeight
@@ -2407,9 +2750,13 @@
let bounds = card.containerLayer.bounds
let isSelected = card.item.window.id == effectiveSelection?.windowID
let isHovered = card.item.window.id == hoveredWindowID
- applyWaterfallCardVisualState(card, selected: shouldShowSelectedVisual(isSelected), hovered: isHovered)
+ applyWaterfallCardVisualState(
+ card,
+ selected: shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered),
+ hovered: isHovered
+ )
- let outerInset: CGFloat = 8
+ let outerInset: CGFloat = 0
let titleBarHeight: CGFloat = 26
let titleGap: CGFloat = 6
let titleBarFrame = CGRect(
@@ -2470,7 +2817,7 @@
}
private func applyWaterfallCardVisualState(_ card: WaterfallCardLayers, selected: Bool, hovered: Bool) {
- let isSpaceFocused = card.item.primarySpaceID == activeSpaceFocusID
+ let isSpaceFocused = isWaterfallCardSpaceFocused(card)
card.containerLayer.backgroundColor = (selected
? NSColor.windowBackgroundColor.withAlphaComponent(0.94)
: hovered
@@ -2527,13 +2874,14 @@
let size: CGFloat = isButtonHovered ? 24 : 18
let buttonFill = isButtonHovered
? NSColor.systemRed.withAlphaComponent(0.94)
- : NSColor.labelColor.withAlphaComponent(0.24)
+ : NSColor.white.withAlphaComponent(0.34)
let buttonStroke = isButtonHovered
? NSColor.white.withAlphaComponent(0.76)
- : NSColor.white.withAlphaComponent(0.52)
+ : NSColor.white.withAlphaComponent(0.46)
let glyphStroke = isButtonHovered
? NSColor.white
- : NSColor.secondaryLabelColor.withAlphaComponent(0.92)
+ : NSColor.white.withAlphaComponent(0.88)
+
button.containerLayer.frame = CGRect(
x: anchorTopRight.x - size / 2,
y: anchorTopRight.y - size / 2,
@@ -2545,10 +2893,10 @@
button.containerLayer.borderColor = buttonStroke.cgColor
button.containerLayer.opacity = visible ? 1 : 0
button.containerLayer.zPosition = isButtonHovered ? 80 : 60
- button.containerLayer.shadowOpacity = visible ? (isButtonHovered ? 0.22 : 0.07) : 0
+ button.containerLayer.shadowOpacity = visible ? (isButtonHovered ? 0.22 : 0.04) : 0
button.containerLayer.shadowRadius = isButtonHovered ? 9 : 5
button.glyphLayer.frame = CGRect(x: 0, y: 0, width: size, height: size)
- button.glyphLayer.lineWidth = isButtonHovered ? 2.1 : 1.7
+ button.glyphLayer.lineWidth = isButtonHovered ? 2.1 : 1.45
button.glyphLayer.strokeColor = glyphStroke.cgColor
button.glyphLayer.path = closeGlyphPath(size: size, hovered: isButtonHovered)
}
@@ -2641,6 +2989,80 @@
layers.confirmButtonTextLayer.frame = layers.confirmButtonLayer.bounds.insetBy(dx: 0, dy: 7)
}
+ private func setCloseFeedback(_ feedback: CloseFeedback, autoHideAfter delay: TimeInterval?) {
+ closeFeedbackHideTask?.cancel()
+ closeFeedback = feedback
+ closeFeedbackLayer.isHidden = false
+ closeFeedbackLayer.opacity = 1
+ closeFeedbackTextLayer.string = feedback.message
+ switch feedback.kind {
+ case .progress:
+ closeFeedbackLayer.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.94).cgColor
+ closeFeedbackLayer.borderColor = NSColor.separatorColor.withAlphaComponent(0.28).cgColor
+ closeFeedbackTextLayer.foregroundColor = NSColor.secondaryLabelColor.withAlphaComponent(0.96).cgColor
+ case .failure:
+ closeFeedbackLayer.backgroundColor = NSColor.systemRed.withAlphaComponent(0.10).cgColor
+ closeFeedbackLayer.borderColor = NSColor.systemRed.withAlphaComponent(0.32).cgColor
+ closeFeedbackTextLayer.foregroundColor = NSColor.systemRed.withAlphaComponent(0.95).cgColor
+ }
+ needsLayout = true
+
+ guard let delay else {
+ closeFeedbackHideTask = nil
+ return
+ }
+
+ closeFeedbackHideTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ guard !Task.isCancelled,
+ let self,
+ self.closeFeedback == feedback
+ else {
+ return
+ }
+ self.clearCloseFeedback()
+ }
+ }
+
+ private func layoutCloseFeedback() {
+ guard let feedback = closeFeedback else {
+ closeFeedbackLayer.isHidden = true
+ closeFeedbackLayer.opacity = 0
+ return
+ }
+
+ closeFeedbackLayer.isHidden = false
+ closeFeedbackLayer.opacity = 1
+ let font = NSFont.systemFont(
+ ofSize: 13,
+ weight: feedback.kind == .failure ? .medium : .regular
+ )
+ closeFeedbackTextLayer.font = font
+ closeFeedbackTextLayer.fontSize = 13
+ let textWidth = (feedback.message as NSString).size(
+ withAttributes: [.font: font]
+ ).width
+ let maxWidth = max(180, glassLayer.bounds.width - 48)
+ let width = min(max(180, textWidth + 34), min(460, maxWidth))
+ let height: CGFloat = 34
+ let x = max(24, (glassLayer.bounds.width - width) / 2)
+ let preferredY = waterfallLayer.frame.maxY - height - 14
+ let y = min(
+ max(16, preferredY),
+ max(16, glassLayer.bounds.height - height - 16)
+ )
+
+ closeFeedbackLayer.frame = CGRect(x: x, y: y, width: width, height: height)
+ closeFeedbackLayer.cornerRadius = height / 2
+ closeFeedbackLayer.shadowPath = CGPath(
+ roundedRect: closeFeedbackLayer.bounds,
+ cornerWidth: closeFeedbackLayer.cornerRadius,
+ cornerHeight: closeFeedbackLayer.cornerRadius,
+ transform: nil
+ )
+ closeFeedbackTextLayer.frame = closeFeedbackLayer.bounds.insetBy(dx: 17, dy: 8)
+ }
+
private func closeConfirmationBubblePath(
bounds: CGRect,
contentFrame: CGRect,
@@ -2726,18 +3148,23 @@
}
pendingCloseTarget = nil
+ pendingCloseRequest = nil
pendingCloseOptOut = false
closeConfirmationLayers.containerLayer.isHidden = true
closeConfirmationArrowEdge = nil
- if pendingCloseOptOut {
- onCloseConfirmationDisabled?()
- }
onRequestClose?(request)
}
private func beginCloseConfirmation(for target: CloseTarget) {
if closeConfirmationRequired {
+ guard let request = closeRequest(for: target) else {
+ DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.missingRequest", [
+ "targetKind": target.kindDescription
+ ])
+ return
+ }
pendingCloseTarget = target
+ pendingCloseRequest = request
pendingCloseOptOut = false
DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.show", [
"targetKind": target.kindDescription
@@ -2750,23 +3177,30 @@
}
private func confirmPendingClose() {
- guard let target = pendingCloseTarget else { return }
+ guard let target = pendingCloseTarget,
+ let request = pendingCloseRequest
+ else { return }
let shouldDisableConfirmation = pendingCloseOptOut
pendingCloseTarget = nil
+ pendingCloseRequest = nil
pendingCloseOptOut = false
closeConfirmationLayers.containerLayer.isHidden = true
closeConfirmationArrowEdge = nil
- if shouldDisableConfirmation {
+ if shouldDisableConfirmation && closeConfirmationRequired {
+ closeConfirmationRequired = false
onCloseConfirmationDisabled?()
}
- if let request = closeRequest(for: target) {
- onRequestClose?(request)
- }
+ DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.confirm", [
+ "targetKind": target.kindDescription,
+ "disableFutureConfirmation": shouldDisableConfirmation
+ ])
+ onRequestClose?(request)
}
private func cancelPendingClose() {
DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.cancel")
pendingCloseTarget = nil
+ pendingCloseRequest = nil
pendingCloseOptOut = false
closeConfirmationLayers.containerLayer.isHidden = true
closeConfirmationArrowEdge = nil
@@ -2774,7 +3208,15 @@
}
private func toggleCloseConfirmationOptOut() {
+ if pendingCloseOptOut && !closeConfirmationRequired {
+ return
+ }
pendingCloseOptOut.toggle()
+ if pendingCloseOptOut {
+ closeConfirmationRequired = false
+ onCloseConfirmationDisabled?()
+ DevelopmentDiagnostics.log("quickSwitch.view.closeConfirmation.optOutPersisted")
+ }
needsLayout = true
}
@@ -2941,13 +3383,18 @@
return color.withAlphaComponent(alpha)
}
+ private func isWaterfallCardSpaceFocused(_ card: WaterfallCardLayers) -> Bool {
+ guard let activeSpaceFocusID else { return false }
+ return card.item.primarySpaceID == activeSpaceFocusID
+ }
+
private func waterfallColumnWidth() -> CGFloat {
guard waterfallColumns.count > 0 else { return WaterfallMetrics.columnWidth }
let visibleColumns = min(4, max(1, waterfallColumns.count))
let fittingWidth = (
waterfallLayer.bounds.width
- - WaterfallMetrics.columnPadding * 2
+ - WaterfallMetrics.contentHorizontalPadding * 2
- CGFloat(max(0, visibleColumns - 1)) * WaterfallMetrics.gap
) / CGFloat(visibleColumns)
return max(
@@ -2975,7 +3422,7 @@
1,
waterfallLayer.bounds.height
- WaterfallMetrics.headerHeight
- - WaterfallMetrics.columnPadding * 2
+ - WaterfallMetrics.columnVerticalPadding * 2
)
let contentHeight = CGFloat(column.cards.count) * WaterfallMetrics.cardHeight
+ CGFloat(max(0, column.cards.count - 1)) * WaterfallMetrics.cardGap
@@ -3006,14 +3453,19 @@
}
private func spaceColor(for space: QuickSwitchSpaceViewModel) -> NSColor {
- if space.isCurrent {
- return NSColor.windowBackgroundColor.withAlphaComponent(0.76)
- }
if space.windowCount > 0 {
- return NSColor.controlBackgroundColor.withAlphaComponent(0.56)
+ return NSColor.controlBackgroundColor.withAlphaComponent(0.36)
}
- return NSColor.controlBackgroundColor.withAlphaComponent(0.26)
+ return NSColor.labelColor.withAlphaComponent(space.isCurrent ? 0.07 : 0.05)
+ }
+
+ private var spaceLaneOccupiedColor: NSColor {
+ if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
+ return NSColor(calibratedRed: 10 / 255, green: 132 / 255, blue: 1, alpha: 1)
+ }
+
+ return NSColor(calibratedRed: 0, green: 122 / 255, blue: 1, alpha: 1)
}
private func makeFullscreenMarkerLayer(for space: QuickSwitchSpaceViewModel) -> CALayer? {
@@ -3179,10 +3631,9 @@
return (0..<count).map { _ in
let layer = CALayer()
layer.cornerRadius = 3
- layer.backgroundColor = (space.type == .fullscreen
- ? NSColor.labelColor.withAlphaComponent(0.20)
- : NSColor.labelColor.withAlphaComponent(0.18)
- ).cgColor
+ layer.backgroundColor = spaceLaneOccupiedColor
+ .withAlphaComponent(space.isCurrent ? 0.72 : 0.58)
+ .cgColor
return layer
}
}
@@ -3238,9 +3689,9 @@
private func fullscreenMarkerFrame(in segmentBounds: CGRect) -> CGRect {
CGRect(
x: 10,
- y: 30,
+ y: 12,
width: max(1, segmentBounds.width - 20),
- height: max(1, segmentBounds.height - 58)
+ height: max(1, segmentBounds.height - 64)
)
}
@@ -3282,7 +3733,9 @@
"closeButtonVisible": item.closeButton.containerLayer.opacity > 0,
"closeButtonFrame": dictionary(from: item.closeButton.containerLayer.frame),
"closeButtonSize": Double(item.closeButton.containerLayer.bounds.width),
- "closeButtonFillColor": colorDictionary(from: item.closeButton.containerLayer.backgroundColor)
+ "closeButtonFillColor": colorDictionary(from: item.closeButton.containerLayer.backgroundColor),
+ "closeButtonGlyphColor": colorDictionary(from: item.closeButton.glyphLayer.strokeColor),
+ "closeButtonGlyphLineWidth": Double(item.closeButton.glyphLayer.lineWidth)
] as [String: Any]
}
}
@@ -3290,12 +3743,13 @@
private func appShelfVisualStates(for item: AppShelfItemLayers) -> [String] {
var states: [String] = []
let isSelected = item.item.appGroupIndex == effectiveSelection?.appGroupIndex
- if shouldShowSelectedVisual(isSelected) {
+ let isHovered = item.item.appGroupIndex == effectiveHoveredAppGroupIndex
+ if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
states.append("selected")
} else if isSelected {
states.append("selectedVisualSuppressed")
}
- if item.item.appGroupIndex == effectiveHoveredAppGroupIndex {
+ if isHovered {
states.append("hover")
}
if spaceFocusWindowCount(for: item.item.appGroupIndex) > 0 {
@@ -3308,21 +3762,39 @@
return states
}
+ private func waterfallColumnVisualStates(for column: WaterfallColumnLayers) -> [String] {
+ var states: [String] = []
+ if column.column.appGroupIndex == effectiveHoveredAppGroupIndex {
+ states.append("hover")
+ }
+ if spaceFocusWindowCount(for: column.column.appGroupIndex) > 0 {
+ states.append("spaceFocused")
+ }
+ return states
+ }
+
private func waterfallColumnReports() -> [[String: Any]] {
waterfallColumns.map { column in
let visibleFrame = column.containerLayer.frame.offsetBy(dx: waterfallContentLayer.frame.minX, dy: 0)
let appGroupIndex = column.column.appGroupIndex
+ let isHovered = appGroupIndex == effectiveHoveredAppGroupIndex
return [
"appGroupIndex": appGroupIndex,
"appName": column.column.app.name,
"bundleIdentifier": column.column.app.bundleIdentifier,
"windowCount": column.column.windows.count,
+ "isHovered": isHovered,
+ "visualStates": waterfallColumnVisualStates(for: column),
"spaceFocusWindowCount": spaceFocusWindowCount(for: appGroupIndex),
"isSpaceFocused": spaceFocusWindowCount(for: appGroupIndex) > 0,
"spaceFocusDirection": spaceFocusDirection(for: appGroupIndex),
"frame": dictionary(from: column.containerLayer.frame),
"visibleFrame": dictionary(from: visibleFrame),
+ "appNameFrame": dictionary(from: column.appNameLayer.frame),
+ "appNameAlignment": column.appNameLayer.alignmentMode.rawValue,
+ "appNameColor": colorDictionary(from: column.appNameLayer.foregroundColor),
+ "countFrame": dictionary(from: column.countLayer.frame),
"verticalScrollOffset": Double(waterfallColumnScrollOffsets[appGroupIndex] ?? 0),
"maxVerticalScrollOffset": Double(waterfallMaxVerticalScrollOffset(for: appGroupIndex)),
"cards": waterfallCardReports(for: column)
@@ -3418,6 +3890,8 @@
"closeButtonFrame": dictionary(from: card.closeButton.containerLayer.frame),
"closeButtonSize": Double(card.closeButton.containerLayer.bounds.width),
"closeButtonFillColor": colorDictionary(from: card.closeButton.containerLayer.backgroundColor),
+ "closeButtonGlyphColor": colorDictionary(from: card.closeButton.glyphLayer.strokeColor),
+ "closeButtonGlyphLineWidth": Double(card.closeButton.glyphLayer.lineWidth),
"shineVisible": card.shineLayer.opacity > 0,
"zPosition": Double(card.containerLayer.zPosition),
"frame": dictionary(from: card.containerLayer.frame),
@@ -3513,15 +3987,16 @@
private func waterfallCardVisualStates(for card: WaterfallCardLayers) -> [String] {
var states: [String] = []
let isSelected = card.item.window.id == effectiveSelection?.windowID
- if shouldShowSelectedVisual(isSelected) {
+ let isHovered = card.item.window.id == hoveredWindowID
+ if shouldShowSelectedVisual(isSelected: isSelected, isHovered: isHovered) {
states.append("selected")
} else if isSelected {
states.append("selectedVisualSuppressed")
}
- if card.item.window.id == hoveredWindowID {
+ if isHovered {
states.append("hover")
}
- if card.item.primarySpaceID == activeSpaceFocusID {
+ if isWaterfallCardSpaceFocused(card) {
states.append("spaceFocused")
}
if card.item.window.isMinimized {
@@ -3537,16 +4012,47 @@
window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
}
+ private func spaceLaneDisplayGroupReports() -> [[String: Any]] {
+ spaceLaneDisplayGroups.map { group in
+ let visibleFrame = group.containerLayer.frame.offsetBy(dx: spaceLaneContentLayer.frame.minX, dy: 0)
+ return [
+ "displayUUID": group.display.displayUUID,
+ "displayIndex": group.display.displayIndex,
+ "displayLabel": group.display.displayLabel,
+ "physical": group.display.physical,
+ "spaceCount": group.segments.count,
+ "spaceIDs": group.segments.map(\.space.id),
+ "spaceLabels": group.segments.map(\.space.label),
+ "frame": dictionary(from: group.containerLayer.frame),
+ "visibleFrame": dictionary(from: visibleFrame),
+ "backgroundColor": colorDictionary(from: group.containerLayer.backgroundColor),
+ "borderColor": colorDictionary(from: group.containerLayer.borderColor),
+ "borderWidth": Double(group.containerLayer.borderWidth),
+ "shadowOpacity": Double(group.containerLayer.shadowOpacity),
+ "shadowRadius": Double(group.containerLayer.shadowRadius),
+ "groupGap": Double(SpaceLaneMetrics.groupGap),
+ "segmentGap": Double(SpaceLaneMetrics.segmentGap)
+ ] as [String: Any]
+ }
+ }
+
private func spaceLaneSegmentReports() -> [[String: Any]] {
currentViewModel?.displays.flatMap { display in
display.spaces.compactMap { space in
guard let segment = spaceLaneSegments.first(where: { $0.space.id == space.id }) else {
return nil
}
- let visibleFrame = segment.containerLayer.frame.offsetBy(dx: spaceLaneContentLayer.frame.minX, dy: 0)
+ let isAppAssociated = appHoverAssociatedSpaceIDs.contains(space.id)
+ let isWindowAssociated = space.id == windowHoverAssociatedSpaceID
+ let group = spaceLaneDisplayGroup(containingSpaceID: space.id)
+ let visibleFrame = segment.containerLayer.frame.offsetBy(
+ dx: (group?.containerLayer.frame.minX ?? 0) + spaceLaneContentLayer.frame.minX,
+ dy: group?.containerLayer.frame.minY ?? 0
+ )
return [
"displayUUID": display.displayUUID,
"displayLabel": display.displayLabel,
+ "spaceID": space.id,
"label": space.label,
"type": spaceTypeName(space.type),
"visualStates": visualStates(for: space),
@@ -3554,15 +4060,21 @@
"fullscreenMarkerKind": segment.fullscreenMarkerLayer == nil ? "none" : "cornerBracket",
"fullscreenMarkerFrame": segment.fullscreenMarkerLayer.map { dictionary(from: $0.frame) } ?? NSNull(),
"windowBlockCount": segment.windowBlocks.count,
+ "windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) },
+ "windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) },
"backgroundColor": colorDictionary(from: segment.containerLayer.backgroundColor),
+ "borderColor": colorDictionary(from: segment.containerLayer.borderColor),
+ "borderWidth": Double(segment.containerLayer.borderWidth),
"shadowOpacity": Double(segment.containerLayer.shadowOpacity),
"shadowRadius": Double(segment.containerLayer.shadowRadius),
"appFrame": dictionary(from: segment.appLayer.frame),
"frame": dictionary(from: segment.containerLayer.frame),
"visibleFrame": dictionary(from: visibleFrame),
"isCurrent": space.isCurrent,
- "isHovered": space.id == hoveredSpaceID,
+ "isHovered": space.id == activeSpaceFocusID,
"isFocused": space.id == activeSpaceFocusID,
+ "isAppAssociated": isAppAssociated,
+ "isWindowAssociated": isWindowAssociated,
"windowCount": space.windowCount,
"appCount": space.appCount,
"appNames": space.appNames
@@ -3571,12 +4083,24 @@
} ?? []
}
+ private func spaceLaneDisplayGroup(containingSpaceID spaceID: UInt64) -> SpaceLaneDisplayGroupLayers? {
+ spaceLaneDisplayGroups.first { group in
+ group.segments.contains { $0.space.id == spaceID }
+ }
+ }
+
private func visualStates(for space: QuickSwitchSpaceViewModel) -> [String] {
var states: [String] = []
if space.isCurrent {
states.append("current")
}
- if space.id == hoveredSpaceID {
+ if appHoverAssociatedSpaceIDs.contains(space.id) {
+ states.append("appAssociated")
+ }
+ if space.id == windowHoverAssociatedSpaceID {
+ states.append("windowAssociated")
+ }
+ if space.id == activeSpaceFocusID {
states.append("hover")
}
if space.id == activeSpaceFocusID {
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index fa79190..11aa41d 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -5,13 +5,21 @@
@MainActor
final class QuickSwitchSessionController {
static let overlayTitle = "Aligner Quick Switch"
+ private static let closeRequestStillPresentMessage = "已发送关闭请求,但窗口仍在列表中。"
+ private static let closeTargetNotFoundMessage = "没有找到这个窗口的关闭控件,已刷新列表。"
+ private static let closeUnsupportedMessage = "这个窗口没有可用的关闭按钮。"
+ private static let closeFailedMessage = "关闭请求没有被系统接受。"
+ private static let closeVerificationRetryDelays: [TimeInterval] = [0.35, 0.70, 1.20]
private let view = QuickSwitchRootView()
private let snapshotLoader: any QuickSwitchSnapshotLoading
private let screenshotProvider: any ScreenshotProviderProtocol
private let windowActivationService: any WindowActivationServiceProtocol
+ private let windowCloseService: any WindowCloseServiceProtocol
private let systemCriticalWindowDetector: any SystemCriticalWindowDetecting
private let disableScreenshotRefresh: Bool
+ private var closeConfirmationRequired: Bool
+ private let onCloseConfirmationDisabled: (() -> Void)?
private let debugOverlayWidth: CGFloat?
private let debugKeySequence: [String]
private let debugMouseSequence: [String]
@@ -40,6 +48,14 @@
private var lastActivationWindowID: UInt32?
private var lastActivationResult: WindowActivationResult?
private var lastActivationError: String?
+ private var lastCloseTargetKind: String?
+ private var lastCloseAppGroupIndex: Int?
+ private var lastCloseAppName: String?
+ private var lastCloseWindowID: UInt32?
+ private var lastCloseResult: WindowCloseResult?
+ private var lastCloseError: String?
+ private var pendingCloseVerification: PendingCloseVerification?
+ private var suppressedCloseTargets: [SuppressedCloseTarget] = []
private var lastDismissReason: DismissReason?
private var lastSystemCriticalAction: String?
private var lastSystemCriticalMonitorSource: String?
@@ -58,6 +74,26 @@
private var lifecycleResidualVisibleOverlayWindows = 0
var onSnapshotUpdated: (() -> Void)?
+ private struct PendingCloseVerification {
+ let request: QuickSwitchCloseRequest
+ let generation: Int
+ let attempt: Int
+ }
+
+ private enum SuppressedCloseTarget: Equatable {
+ case app(AlignerApp)
+ case window(UInt32)
+
+ init(_ request: QuickSwitchCloseRequest) {
+ switch request {
+ case .app:
+ self = .app(request.app)
+ case .window:
+ self = .window(request.windowID ?? 0)
+ }
+ }
+ }
+
init(
snapshotLoader: any QuickSwitchSnapshotLoading = LiveQuickSwitchSnapshotLoader(),
screenshotProvider: any ScreenshotProviderProtocol = ScreenCaptureKitScreenshotProvider(
@@ -66,8 +102,13 @@
windowActivationService: any WindowActivationServiceProtocol = CGWindowAXWindowService(
spaceIDsByWindowIDProvider: { _ in [:] }
),
+ windowCloseService: any WindowCloseServiceProtocol = CGWindowAXWindowService(
+ spaceIDsByWindowIDProvider: { _ in [:] }
+ ),
systemCriticalWindowDetector: any SystemCriticalWindowDetecting = CGWindowSystemCriticalWindowDetector(),
disableScreenshotRefresh: Bool = false,
+ closeConfirmationRequired: Bool = true,
+ onCloseConfirmationDisabled: (() -> Void)? = nil,
debugHoveredAppGroupIndex: Int? = nil,
debugOverlayWidth: CGFloat? = nil,
debugKeySequence: [String] = [],
@@ -77,8 +118,11 @@
self.snapshotLoader = snapshotLoader
self.screenshotProvider = screenshotProvider
self.windowActivationService = windowActivationService
+ self.windowCloseService = windowCloseService
self.systemCriticalWindowDetector = systemCriticalWindowDetector
self.disableScreenshotRefresh = disableScreenshotRefresh
+ self.closeConfirmationRequired = closeConfirmationRequired
+ self.onCloseConfirmationDisabled = onCloseConfirmationDisabled
self.debugOverlayWidth = debugOverlayWidth
self.debugKeySequence = debugKeySequence
self.debugMouseSequence = debugMouseSequence
@@ -107,6 +151,14 @@
lastActivationWindowID = nil
lastActivationResult = nil
lastActivationError = nil
+ lastCloseTargetKind = nil
+ lastCloseAppGroupIndex = nil
+ lastCloseAppName = nil
+ lastCloseWindowID = nil
+ lastCloseResult = nil
+ lastCloseError = nil
+ pendingCloseVerification = nil
+ suppressedCloseTargets = []
lastDismissReason = nil
lastSystemCriticalAction = nil
lastSystemCriticalMonitorSource = nil
@@ -128,6 +180,15 @@
view.onCommitSelection = { [weak self] selection, source in
self?.commitSelection(selection, source: source)
}
+ view.onRequestClose = { [weak self] request in
+ self?.requestClose(request)
+ }
+ view.onCloseConfirmationDisabled = { [weak self] in
+ guard let self else { return }
+ self.closeConfirmationRequired = false
+ self.onCloseConfirmationDisabled?()
+ }
+ view.setCloseConfirmationRequired(closeConfirmationRequired)
view.apply(viewModel: nil)
view.beginPerformanceFirstFrameMeasurement()
coordinator.openQuickSwitch()
@@ -157,6 +218,8 @@
snapshotTask = nil
screenshotTask?.cancel()
screenshotTask = nil
+ pendingCloseVerification = nil
+ view.clearCloseFeedback()
invalidateSystemCriticalMonitoring()
coordinator.closeQuickSwitch(reason: reason)
lifecycleResidualOverlayWindows = overlayWindowCount()
@@ -212,6 +275,13 @@
"lastActivationWindowID": lastActivationWindowID ?? NSNull(),
"lastActivationResult": lastActivationResult.map(Self.activationResultString) ?? NSNull(),
"lastActivationError": lastActivationError ?? NSNull(),
+ "lastCloseTargetKind": lastCloseTargetKind ?? NSNull(),
+ "lastCloseAppGroupIndex": lastCloseAppGroupIndex ?? NSNull(),
+ "lastCloseAppName": lastCloseAppName ?? NSNull(),
+ "lastCloseWindowID": lastCloseWindowID ?? NSNull(),
+ "lastCloseResult": lastCloseResult.map(Self.closeResultString) ?? NSNull(),
+ "lastCloseError": lastCloseError ?? NSNull(),
+ "suppressedCloseTargetCount": suppressedCloseTargets.count,
"lastDismissReason": lastDismissReason.map(Self.dismissReasonString) ?? NSNull(),
"overlayLevel": Int(coordinator.currentLevel),
"overlayMainMenuLevel": Int(CGWindowLevelForKey(.mainMenuWindow)),
@@ -264,16 +334,26 @@
updateLifecycleWindowHighWaterMark()
}
- private func scheduleSnapshotRefresh(for generation: Int) {
+ private func scheduleSnapshotRefresh(
+ for generation: Int,
+ replayDebugCommands: Bool = true,
+ delay: TimeInterval = 0
+ ) {
DevelopmentDiagnostics.log("quickSwitch.snapshot.schedule", [
"generation": generation,
- "visible": isVisible
+ "visible": isVisible,
+ "replayDebugCommands": replayDebugCommands,
+ "delayMilliseconds": Int(delay * 1_000)
])
snapshotTask?.cancel()
let loader = snapshotLoader
let showStartTime = showStartTime
snapshotTask = Task { [weak self] in
guard let self, !Task.isCancelled else { return }
+ if delay > 0 {
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ guard !Task.isCancelled else { return }
+ }
let loadTask = Task.detached(priority: .userInitiated) {
SnapshotLoadResult.load(using: loader, showStartTime: showStartTime)
@@ -285,7 +365,11 @@
}
guard !Task.isCancelled else { return }
- self.applySnapshotResult(result, generation: generation)
+ self.applySnapshotResult(
+ result,
+ generation: generation,
+ replayDebugCommands: replayDebugCommands
+ )
}
}
@@ -432,6 +516,224 @@
onSnapshotUpdated?()
}
+ private func requestClose(_ request: QuickSwitchCloseRequest) {
+ DevelopmentDiagnostics.log("quickSwitch.close.request.start", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "titleHash": DevelopmentDiagnostics.stableFingerprint(request.title),
+ "titleLength": request.title.count
+ ])
+ lastCloseTargetKind = request.kindDescription
+ lastCloseAppGroupIndex = request.appGroupIndex
+ lastCloseAppName = request.appName
+ lastCloseWindowID = request.windowID
+ lastCloseError = nil
+ pendingCloseVerification = nil
+ view.showCloseProgress(for: request)
+
+ do {
+ let result: WindowCloseResult
+ switch request {
+ case .app:
+ result = try windowCloseService.close(app: request.app)
+ case .window:
+ guard let window = request.window else {
+ lastCloseResult = .windowNotFound
+ suppressCloseTarget(request)
+ view.clearCloseFeedback()
+ onSnapshotUpdated?()
+ return
+ }
+ result = try windowCloseService.close(window: window)
+ }
+ lastCloseResult = result
+ DevelopmentDiagnostics.log("quickSwitch.close.request.result", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "result": Self.closeResultString(result)
+ ])
+ if result == .requested {
+ suppressCloseTarget(request)
+ DevelopmentDiagnostics.log("quickSwitch.close.request.refreshAfterClose", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID
+ ])
+ pendingCloseVerification = PendingCloseVerification(
+ request: request,
+ generation: sessionGeneration,
+ attempt: 0
+ )
+ scheduleSnapshotRefresh(
+ for: sessionGeneration,
+ replayDebugCommands: false,
+ delay: Self.closeVerificationRetryDelays[0]
+ )
+ } else if result == .windowNotFound {
+ suppressCloseTarget(request)
+ view.clearCloseFeedback()
+ DevelopmentDiagnostics.log("quickSwitch.close.request.staleTargetRemoved", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID
+ ])
+ scheduleSnapshotRefresh(
+ for: sessionGeneration,
+ replayDebugCommands: false,
+ delay: 0.10
+ )
+ } else {
+ view.showCloseFailure(message: closeFailureMessage(for: result), for: request)
+ }
+ } catch {
+ let errorSummary = DevelopmentDiagnostics.errorSummaryString(error)
+ lastCloseResult = .failed(errorSummary)
+ lastCloseError = errorSummary
+ pendingCloseVerification = nil
+ view.showCloseFailure(message: Self.closeFailedMessage, for: request)
+ var fields: [String: CustomStringConvertible?] = [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "titleHash": DevelopmentDiagnostics.stableFingerprint(request.title),
+ "titleLength": request.title.count
+ ]
+ DevelopmentDiagnostics.errorSummaryFields(error).forEach { fields[$0.key] = $0.value }
+ DevelopmentDiagnostics.log("quickSwitch.close.request.error", fields)
+ }
+
+ onSnapshotUpdated?()
+ }
+
+ private func suppressCloseTarget(_ request: QuickSwitchCloseRequest) {
+ let target = SuppressedCloseTarget(request)
+ if !suppressedCloseTargets.contains(target) {
+ suppressedCloseTargets.append(target)
+ }
+
+ guard let currentViewModel else { return }
+ let visibleViewModel = viewModel(currentViewModel, excluding: suppressedCloseTargets)
+ self.currentViewModel = visibleViewModel
+ view.apply(viewModel: visibleViewModel)
+ DevelopmentDiagnostics.log("quickSwitch.close.optimisticRemove", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "remainingWindowCount": visibleViewModel.windowCount,
+ "suppressedCloseTargetCount": suppressedCloseTargets.count
+ ])
+ }
+
+ private func viewModel(
+ _ viewModel: QuickSwitchViewModel,
+ excluding suppressedTargets: [SuppressedCloseTarget]
+ ) -> QuickSwitchViewModel {
+ guard !suppressedTargets.isEmpty else { return viewModel }
+
+ var nextGlobalIndex = 0
+ let columns = viewModel.waterfallColumns.compactMap { column -> QuickSwitchWaterfallColumnViewModel? in
+ let windows = column.windows.filter { card in
+ !suppressedTargets.contains { target in
+ suppressedTarget(target, matches: card)
+ }
+ }
+ guard !windows.isEmpty else { return nil }
+
+ let reindexedWindows = windows.enumerated().map { windowIndex, card in
+ defer { nextGlobalIndex += 1 }
+ return QuickSwitchWindowCardViewModel(
+ appGroupIndex: column.appGroupIndex,
+ windowIndex: windowIndex,
+ globalIndex: nextGlobalIndex,
+ window: card.window,
+ primarySpaceID: card.primarySpaceID,
+ primarySpaceLabel: card.primarySpaceLabel
+ )
+ }
+
+ return QuickSwitchWaterfallColumnViewModel(
+ appGroupIndex: column.appGroupIndex,
+ app: column.app,
+ windows: reindexedWindows
+ )
+ }
+
+ let columnsByAppGroupIndex = Dictionary(uniqueKeysWithValues: columns.map { ($0.appGroupIndex, $0) })
+ let appShelf = viewModel.appShelf.compactMap { item -> QuickSwitchAppShelfItemViewModel? in
+ guard let column = columnsByAppGroupIndex[item.appGroupIndex] else { return nil }
+ return QuickSwitchAppShelfItemViewModel(
+ appGroupIndex: item.appGroupIndex,
+ app: item.app,
+ windowCount: column.windows.count,
+ primarySpaceIDs: orderedPrimarySpaceIDs(in: column.windows),
+ hasMinimizedWindows: column.windows.contains { $0.window.isMinimized },
+ hasFullscreenWindows: column.windows.contains { $0.window.isFullscreen }
+ )
+ }
+
+ return QuickSwitchViewModel(
+ displays: viewModel.displays,
+ appShelf: appShelf,
+ waterfallColumns: columns,
+ initialSelection: initialSelection(in: columns, fallback: viewModel.initialSelection)
+ )
+ }
+
+ private func orderedPrimarySpaceIDs(in cards: [QuickSwitchWindowCardViewModel]) -> [UInt64] {
+ var seen = Set<UInt64>()
+ var result: [UInt64] = []
+ for card in cards {
+ guard let spaceID = card.primarySpaceID,
+ !seen.contains(spaceID)
+ else {
+ continue
+ }
+ seen.insert(spaceID)
+ result.append(spaceID)
+ }
+ return result
+ }
+
+ private func suppressedTarget(
+ _ target: SuppressedCloseTarget,
+ matches card: QuickSwitchWindowCardViewModel
+ ) -> Bool {
+ switch target {
+ case .window(let windowID):
+ return card.window.id == windowID
+ case .app(let app):
+ return appMatches(card.window.app, app)
+ }
+ }
+
+ private func initialSelection(
+ in columns: [QuickSwitchWaterfallColumnViewModel],
+ fallback: QuickSwitchSelection?
+ ) -> QuickSwitchSelection? {
+ if let fallback,
+ columns.contains(where: { column in
+ column.appGroupIndex == fallback.appGroupIndex
+ && column.windows.contains { $0.window.id == fallback.windowID }
+ }) {
+ return fallback
+ }
+
+ guard let firstCard = columns.first?.windows.first else { return nil }
+ return QuickSwitchSelection(
+ appGroupIndex: firstCard.appGroupIndex,
+ windowIndex: firstCard.windowIndex,
+ windowID: firstCard.window.id
+ )
+ }
+
private func shouldCloseAfterActivation(_ result: WindowActivationResult?) -> Bool {
switch result {
case .activated, .restored, .appActivatedOnly:
@@ -453,7 +755,11 @@
.window
}
- private func applySnapshotResult(_ result: SnapshotLoadResult, generation: Int) {
+ private func applySnapshotResult(
+ _ result: SnapshotLoadResult,
+ generation: Int,
+ replayDebugCommands: Bool
+ ) {
guard generation == sessionGeneration, isVisible else {
DevelopmentDiagnostics.log("quickSwitch.snapshot.drop", [
"resultGeneration": generation,
@@ -466,15 +772,19 @@
switch result {
case .success(let success):
currentSnapshot = success.snapshot
- currentViewModel = success.viewModel
+ let visibleViewModel = viewModel(success.viewModel, excluding: suppressedCloseTargets)
+ currentViewModel = visibleViewModel
lastSnapshotError = nil
snapshotStartElapsedMilliseconds = success.timing.startElapsedMilliseconds
snapshotDurationMilliseconds = success.timing.durationMilliseconds
snapshotRanOnMainThread = success.timing.ranOnMainThread
- view.apply(viewModel: success.viewModel)
+ view.apply(viewModel: visibleViewModel)
view.recordPerformanceFirstFrameIfNeeded()
- view.performDebugKeyboardCommands(debugKeySequence)
- view.performDebugMouseCommands(debugMouseSequence)
+ if replayDebugCommands {
+ view.performDebugKeyboardCommands(debugKeySequence)
+ view.performDebugMouseCommands(debugMouseSequence)
+ }
+ verifyPendingCloseIfNeeded(in: success.viewModel)
DevelopmentDiagnostics.log("quickSwitch.snapshot.success", [
"generation": generation,
"displayCount": success.viewModel.displays.count,
@@ -487,14 +797,14 @@
])
guard isVisible else { return }
if disableScreenshotRefresh {
- view.markScreenshotsNotRequested(for: success.viewModel, reason: "disabledByLaunchOption")
+ view.markScreenshotsNotRequested(for: visibleViewModel, reason: "disabledByLaunchOption")
screenshotTask?.cancel()
screenshotTask = nil
DevelopmentDiagnostics.log("quickSwitch.screenshot.disabledByLaunchOption", [
"generation": generation
])
} else {
- scheduleScreenshotRefresh(for: success.viewModel, generation: generation)
+ scheduleScreenshotRefresh(for: visibleViewModel, generation: generation)
}
case .failure(let failure):
currentSnapshot = nil
@@ -506,6 +816,7 @@
view.apply(viewModel: nil)
screenshotTask?.cancel()
screenshotTask = nil
+ failPendingCloseVerificationIfNeeded(reason: "snapshotFailure")
DevelopmentDiagnostics.log("quickSwitch.snapshot.failure", [
"generation": generation,
"message": failure.message,
@@ -515,6 +826,104 @@
}
onSnapshotUpdated?()
+ }
+
+ private func verifyPendingCloseIfNeeded(in viewModel: QuickSwitchViewModel) {
+ guard let pendingCloseVerification else { return }
+
+ self.pendingCloseVerification = nil
+ let request = pendingCloseVerification.request
+ let stillPresent = closeTargetStillPresent(request, in: viewModel)
+ DevelopmentDiagnostics.log("quickSwitch.close.verify", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "requestedGeneration": pendingCloseVerification.generation,
+ "currentGeneration": sessionGeneration,
+ "attempt": pendingCloseVerification.attempt,
+ "stillPresent": stillPresent
+ ])
+
+ if stillPresent {
+ let nextAttempt = pendingCloseVerification.attempt + 1
+ if nextAttempt < Self.closeVerificationRetryDelays.count {
+ self.pendingCloseVerification = PendingCloseVerification(
+ request: request,
+ generation: sessionGeneration,
+ attempt: nextAttempt
+ )
+ scheduleSnapshotRefresh(
+ for: sessionGeneration,
+ replayDebugCommands: false,
+ delay: Self.closeVerificationRetryDelays[nextAttempt]
+ )
+ } else {
+ view.showCloseFailure(message: Self.closeRequestStillPresentMessage, for: request)
+ }
+ } else {
+ view.clearCloseFeedback()
+ }
+ }
+
+ private func failPendingCloseVerificationIfNeeded(reason: String) {
+ guard let pendingCloseVerification else { return }
+
+ self.pendingCloseVerification = nil
+ let request = pendingCloseVerification.request
+ DevelopmentDiagnostics.log("quickSwitch.close.verify.failed", [
+ "targetKind": request.kindDescription,
+ "appGroupIndex": request.appGroupIndex,
+ "appName": request.appName,
+ "windowID": request.windowID,
+ "requestedGeneration": pendingCloseVerification.generation,
+ "currentGeneration": sessionGeneration,
+ "reason": reason
+ ])
+ view.showCloseFailure(message: Self.closeFailedMessage, for: request)
+ }
+
+ private func closeFailureMessage(for result: WindowCloseResult) -> String {
+ switch result {
+ case .requested:
+ return Self.closeRequestStillPresentMessage
+ case .windowNotFound:
+ return Self.closeTargetNotFoundMessage
+ case .unsupported:
+ return Self.closeUnsupportedMessage
+ case .appNotFound, .failed:
+ return Self.closeFailedMessage
+ }
+ }
+
+ private func closeTargetStillPresent(
+ _ request: QuickSwitchCloseRequest,
+ in viewModel: QuickSwitchViewModel
+ ) -> Bool {
+ switch request {
+ case .window:
+ guard let windowID = request.windowID else { return false }
+ return viewModel.waterfallColumns
+ .flatMap(\.windows)
+ .contains { $0.window.id == windowID }
+ case .app:
+ return viewModel.appShelf.contains { appMatches($0.app, request.app) }
+ || viewModel.waterfallColumns.contains { appMatches($0.app, request.app) }
+ }
+ }
+
+ private func appMatches(_ candidate: AlignerApp, _ target: AlignerApp) -> Bool {
+ if let candidatePID = candidate.processIdentifier,
+ let targetPID = target.processIdentifier {
+ return candidatePID == targetPID
+ }
+
+ if !candidate.bundleIdentifier.isEmpty,
+ candidate.bundleIdentifier == target.bundleIdentifier {
+ return true
+ }
+
+ return candidate.name == target.name
}
private func scheduleScreenshotRefresh(
@@ -650,6 +1059,21 @@
}
}
+ private static func closeResultString(_ result: WindowCloseResult) -> String {
+ switch result {
+ case .requested:
+ return "requested"
+ case .windowNotFound:
+ return "windowNotFound"
+ case .appNotFound:
+ return "appNotFound"
+ case .unsupported:
+ return "unsupported"
+ case .failed(let message):
+ return "failed(\(message))"
+ }
+ }
+
private static func screenshotSourceString(_ source: ScreenshotResolutionSource) -> String {
switch source {
case .realScreenshot:
@@ -742,6 +1166,7 @@
let snapshotLoadDelay: TimeInterval?
let fixtureAppCount: Int?
let fixtureWindowsPerApp: Int?
+ let fixtureMultiDisplay: Bool
let fixtureCandidateFiltering: Bool
let fixtureActivation: Bool
let debugHoveredAppGroupIndex: Int?
@@ -750,6 +1175,7 @@
let debugMouseSequence: [String]
let debugSystemCriticalAfter: TimeInterval?
let debugWindowActivation: Bool
+ let debugWindowClose: Bool
let disableScreenshotRefresh: Bool
let lifecycleCycles: Int?
let lifecycleInterval: TimeInterval
@@ -766,6 +1192,7 @@
snapshotLoadDelay: timeInterval(for: "--round01-snapshot-load-delay", in: arguments),
fixtureAppCount: intValue(for: "--round01-fixture-app-count", in: arguments),
fixtureWindowsPerApp: intValue(for: "--round01-fixture-windows-per-app", in: arguments),
+ fixtureMultiDisplay: arguments.contains("--round01-fixture-multi-display"),
fixtureCandidateFiltering: arguments.contains("--round01-fixture-candidate-filtering"),
fixtureActivation: arguments.contains("--round01-fixture-window-activation"),
debugHoveredAppGroupIndex: intValue(for: "--round01-debug-hover-app-index", in: arguments),
@@ -774,6 +1201,7 @@
debugMouseSequence: stringListValue(for: "--round01-debug-mouse-sequence", in: arguments),
debugSystemCriticalAfter: timeInterval(for: "--round01-debug-system-critical-after", in: arguments),
debugWindowActivation: arguments.contains("--round01-debug-window-activation"),
+ debugWindowClose: arguments.contains("--round01-debug-window-close"),
disableScreenshotRefresh: arguments.contains("--round01-disable-screenshot-refresh"),
lifecycleCycles: intValue(for: "--round01-quick-switch-lifecycle-cycles", in: arguments),
lifecycleInterval: timeInterval(for: "--round01-quick-switch-lifecycle-interval", in: arguments) ?? 0.02,
diff --git a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
index 7421c9f..8d45239 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
@@ -62,11 +62,18 @@
private let appCount: Int
private let windowsPerApp: Int
private let mode: FixtureQuickSwitchMode
+ private let usesMultipleDisplays: Bool
- init(appCount: Int, windowsPerApp: Int = 1, mode: FixtureQuickSwitchMode = .layout) {
+ init(
+ appCount: Int,
+ windowsPerApp: Int = 1,
+ mode: FixtureQuickSwitchMode = .layout,
+ usesMultipleDisplays: Bool = false
+ ) {
self.appCount = max(1, appCount)
self.windowsPerApp = max(1, windowsPerApp)
self.mode = mode
+ self.usesMultipleDisplays = usesMultipleDisplays
}
func snapshot() throws -> QuickSwitchSnapshotLoad {
@@ -81,18 +88,8 @@
}
private func layoutSnapshot() -> QuickSwitchSnapshotLoad {
- let displayUUID = "fixture-display-a"
- let displays = [
- AlignerDisplay(
- uuid: displayUUID,
- physical: true,
- spaces: [
- AlignerSpace(id: 1, type: .user, displayUUID: displayUUID, index: 1),
- AlignerSpace(id: 2, type: .fullscreen, displayUUID: displayUUID, index: 2),
- AlignerSpace(id: 3, type: .user, displayUUID: displayUUID, index: 3)
- ]
- )
- ]
+ let displays = layoutDisplays()
+ let spaceIDs: [UInt64] = usesMultipleDisplays ? [1, 2, 3, 4, 5] : [1, 2, 3]
let windows = (0..<appCount).flatMap { index in
let app = AlignerApp(
bundleIdentifier: "com.example.fixture.\(String(format: "%03d", index))",
@@ -103,7 +100,9 @@
return (0..<windowsPerApp).map { windowIndex in
let spaceSeed = index + windowIndex
- let spaceID = spaceSeed % 5 == 0 ? UInt64(2) : UInt64(spaceSeed % 2 == 0 ? 1 : 3)
+ let spaceID = usesMultipleDisplays
+ ? spaceIDs[spaceSeed % spaceIDs.count]
+ : UInt64(spaceSeed % 5 == 0 ? 2 : spaceSeed % 2 == 0 ? 1 : 3)
return AlignerWindow(
id: UInt32(10_000 + index * 100 + windowIndex),
app: app,
@@ -116,10 +115,48 @@
return QuickSwitchSnapshotLoad(
snapshot: QuickSwitchSnapshotBuilder.snapshot(displays: displays, windows: windows),
- currentSpaceIDs: [2]
+ currentSpaceIDs: usesMultipleDisplays ? [2, 4] : [2]
)
}
+ private func layoutDisplays() -> [AlignerDisplay] {
+ let displayAUUID = "fixture-display-a"
+ guard usesMultipleDisplays else {
+ return [
+ AlignerDisplay(
+ uuid: displayAUUID,
+ physical: true,
+ spaces: [
+ AlignerSpace(id: 1, type: .user, displayUUID: displayAUUID, index: 1),
+ AlignerSpace(id: 2, type: .fullscreen, displayUUID: displayAUUID, index: 2),
+ AlignerSpace(id: 3, type: .user, displayUUID: displayAUUID, index: 3)
+ ]
+ )
+ ]
+ }
+
+ let displayBUUID = "fixture-display-b"
+ return [
+ AlignerDisplay(
+ uuid: displayAUUID,
+ physical: true,
+ spaces: [
+ AlignerSpace(id: 1, type: .user, displayUUID: displayAUUID, index: 1),
+ AlignerSpace(id: 2, type: .fullscreen, displayUUID: displayAUUID, index: 2),
+ AlignerSpace(id: 3, type: .user, displayUUID: displayAUUID, index: 3)
+ ]
+ ),
+ AlignerDisplay(
+ uuid: displayBUUID,
+ physical: true,
+ spaces: [
+ AlignerSpace(id: 4, type: .user, displayUUID: displayBUUID, index: 1),
+ AlignerSpace(id: 5, type: .user, displayUUID: displayBUUID, index: 2)
+ ]
+ )
+ ]
+ }
+
private func candidateFilteringSnapshot() -> QuickSwitchSnapshotLoad {
let displayUUID = "fixture-display-a"
let displays = [
diff --git a/C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift b/C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift
index feda3eb..66b5af1 100644
--- a/C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift
+++ b/C1.source/Sources/AlignerCore/Protocols/ServiceProtocols.swift
@@ -70,6 +70,19 @@
func activate(window: AlignerWindow) throws -> WindowActivationResult
}
+public enum WindowCloseResult: Equatable, Sendable {
+ case requested
+ case windowNotFound
+ case appNotFound
+ case unsupported
+ case failed(String)
+}
+
+public protocol WindowCloseServiceProtocol: AnyObject {
+ func close(window: AlignerWindow) throws -> WindowCloseResult
+ func close(app: AlignerApp) throws -> WindowCloseResult
+}
+
@MainActor
public protocol ScreenshotProviderProtocol {
func screenshot(for window: AlignerWindow) async throws -> NSImage
diff --git a/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift b/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
index 6357849..586fc83 100644
--- a/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
+++ b/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
@@ -83,6 +83,7 @@
guard !record.isDesktopElement else { return false }
guard !record.isSystemCritical else { return false }
guard record.isInteractable else { return false }
+ guard !isKnownClosedShottrCGPlaceholder(record) else { return false }
guard !isCGOnlyOffscreenGhost(record) else { return false }
guard !isKnownClosedDocumentPlaceholder(record) else { return false }
@@ -91,7 +92,7 @@
private static func hasSupportedActivationPolicy(_ record: WindowEnumerationRecord) -> Bool {
record.activationPolicy == .regular
- || (record.activationPolicy == .accessory && isKnownCGOnlyOffscreenMainWindow(record))
+ || (record.activationPolicy == .accessory && isKnownAccessoryMainWindow(record))
}
public static func window(from record: WindowEnumerationRecord) -> AlignerWindow? {
@@ -132,6 +133,10 @@
return false
}
+ if !record.spaceIDs.isEmpty {
+ return false
+ }
+
return !isKnownCGOnlyOffscreenMainWindow(record)
}
@@ -141,8 +146,6 @@
switch record.app.bundleIdentifier {
case "com.apple.finder":
return !trimmedTitle.isEmpty && isLargeEnough(record.size)
- case "cc.ffitch.shottr":
- return trimmedTitle == "Shottr" && isLargeEnough(record.size)
case "io.dcloud.HBuilderX":
return !trimmedTitle.isEmpty && isLargeEnough(record.size)
default:
@@ -150,6 +153,27 @@
}
}
+ private static func isKnownAccessoryMainWindow(_ record: WindowEnumerationRecord) -> Bool {
+ let trimmedTitle = record.title.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ return record.app.bundleIdentifier == "cc.ffitch.shottr"
+ && trimmedTitle == "Shottr"
+ && record.isOnscreen
+ && isLargeEnough(record.size)
+ }
+
+ private static func isKnownClosedShottrCGPlaceholder(_ record: WindowEnumerationRecord) -> Bool {
+ let trimmedTitle = record.title.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ return record.app.bundleIdentifier == "cc.ffitch.shottr"
+ && trimmedTitle == "Shottr"
+ && record.identifierSource == .cgWindow
+ && !record.hasAXBacking
+ && !record.isOnscreen
+ && !record.isMinimized
+ && !record.isFullscreen
+ }
+
private static func isKnownClosedDocumentPlaceholder(_ record: WindowEnumerationRecord) -> Bool {
let trimmedTitle = record.title.trimmingCharacters(in: .whitespacesAndNewlines)
return record.identifierSource == .cgWindow
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index 2dcabd3..298f686 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -212,12 +212,25 @@
let record = makeWindowRecord(
title: "Closed Document.md",
hasAXBacking: false,
- isOnscreen: false,
- spaceIDs: [5]
+ isOnscreen: false
)
XCTAssertFalse(WindowEnumerationPolicy.shouldInclude(record))
XCTAssertNil(WindowEnumerationPolicy.window(from: record))
+ }
+
+ func testWindowEnumerationPolicyKeepsCGOnlyOffscreenWindowsWithKnownSpaceIDs() {
+ let record = makeWindowRecord(
+ title: "Other Space Document.md",
+ hasAXBacking: false,
+ isOnscreen: false,
+ spaceIDs: [5]
+ )
+
+ let window = WindowEnumerationPolicy.window(from: record)
+
+ XCTAssertEqual(window?.title, "Other Space Document.md")
+ XCTAssertEqual(window?.spaceIDs, [5])
}
func testWindowEnumerationPolicyKeepsAXBackedOffscreenWindows() {
@@ -283,13 +296,13 @@
XCTAssertNil(WindowEnumerationPolicy.window(from: tinyRecord))
}
- func testWindowEnumerationPolicyKeepsShottrCGOnlyAnnotationWindow() {
+ func testWindowEnumerationPolicyExcludesClosedShottrCGPlaceholderButKeepsVisibleWindow() {
let shottrApp = AlignerApp(
bundleIdentifier: "cc.ffitch.shottr",
name: "Shottr",
category: .generic
)
- let annotationRecord = WindowEnumerationRecord(
+ let closedPlaceholderRecord = WindowEnumerationRecord(
id: 78,
app: shottrApp,
activationPolicy: .accessory,
@@ -298,6 +311,16 @@
subrole: .standard,
hasAXBacking: false,
isOnscreen: false
+ )
+ let visibleRecord = WindowEnumerationRecord(
+ id: 79,
+ app: shottrApp,
+ activationPolicy: .accessory,
+ title: "Shottr",
+ size: CGSize(width: 1710, height: 997),
+ subrole: .standard,
+ hasAXBacking: false,
+ isOnscreen: true
)
let utilityRecord = WindowEnumerationRecord(
id: 102,
@@ -310,7 +333,8 @@
isOnscreen: false
)
- XCTAssertEqual(WindowEnumerationPolicy.window(from: annotationRecord)?.title, "Shottr")
+ XCTAssertNil(WindowEnumerationPolicy.window(from: closedPlaceholderRecord))
+ XCTAssertEqual(WindowEnumerationPolicy.window(from: visibleRecord)?.title, "Shottr")
XCTAssertNil(WindowEnumerationPolicy.window(from: utilityRecord))
}
diff --git a/C3.tools/generate-app-icon.py b/C3.tools/generate-app-icon.py
index bb01b28..d75bc57 100755
--- a/C3.tools/generate-app-icon.py
+++ b/C3.tools/generate-app-icon.py
@@ -1,241 +1,82 @@
#!/usr/bin/env python3
-"""Generate Aligner's macOS app icon assets."""
+"""Sync Aligner's macOS app icon assets from the design handoff."""
from __future__ import annotations
import json
import shutil
-import subprocess
from pathlib import Path
-from PIL import Image, ImageDraw, ImageFilter
-
-ROOT = Path(__file__).resolve().parents[1]
-RESOURCES = ROOT / "C1.source" / "Resources"
+OUTPUT_ROOT = Path(__file__).resolve().parents[1]
+PROJECT_ROOT = OUTPUT_ROOT.parent
+RESOURCES = OUTPUT_ROOT / "C1.source" / "Resources"
APPICONSET = RESOURCES / "Assets.xcassets" / "AppIcon.appiconset"
ICNS_PATH = RESOURCES / "AppIcon.icns"
-BASE_SIZE = 1024
-SCALE = 4
-CANVAS_SIZE = BASE_SIZE * SCALE
+HANDOFF_DIR = (
+ PROJECT_ROOT
+ / "02-P"
+ / "Round01-QuickSwitch-MVP"
+ / "02.ui设计稿"
+ / "AppIcon-D1-BigBars-Handoff-20260604"
+)
+HANDOFF_ICONSET = HANDOFF_DIR / "AppIcon-D1-BigBars.iconset"
+HANDOFF_ICNS = HANDOFF_DIR / "Aligner-AppIcon-D1-BigBars.icns"
+HANDOFF_PREVIEW = HANDOFF_DIR / "Aligner-AppIcon-D1-BigBars-1024.png"
+
+ICON_FILES = [
+ "icon_16x16.png",
+ "icon_16x16@2x.png",
+ "icon_32x32.png",
+ "icon_32x32@2x.png",
+ "icon_128x128.png",
+ "icon_128x128@2x.png",
+ "icon_256x256.png",
+ "icon_256x256@2x.png",
+ "icon_512x512.png",
+ "icon_512x512@2x.png",
+]
-def s(value: float) -> int:
- return round(value * SCALE)
+def require_file(path: Path) -> None:
+ if not path.is_file():
+ raise FileNotFoundError(f"missing app icon source: {path}")
-def sb(bbox: tuple[float, float, float, float]) -> tuple[int, int, int, int]:
- return tuple(s(value) for value in bbox)
+def load_contents_json() -> dict:
+ contents_path = HANDOFF_ICONSET / "Contents.json"
+ require_file(contents_path)
+ with contents_path.open("r", encoding="utf-8") as file:
+ return json.load(file)
-def rounded_mask(size: tuple[int, int], bbox: tuple[int, int, int, int], radius: int) -> Image.Image:
- mask = Image.new("L", size, 0)
- ImageDraw.Draw(mask).rounded_rectangle(bbox, radius=radius, fill=255)
- return mask
-
-
-def composite_masked(base: Image.Image, layer: Image.Image, mask: Image.Image) -> None:
- base.alpha_composite(Image.composite(layer, Image.new("RGBA", base.size), mask))
-
-
-def vertical_gradient(
- size: tuple[int, int],
- top: tuple[int, int, int, int],
- bottom: tuple[int, int, int, int],
-) -> Image.Image:
- image = Image.new("RGBA", size)
- draw = ImageDraw.Draw(image)
- height = max(size[1] - 1, 1)
- for y in range(size[1]):
- t = y / height
- color = tuple(round(top[i] * (1 - t) + bottom[i] * t) for i in range(4))
- draw.line((0, y, size[0], y), fill=color)
- return image
-
-
-def add_shadow(
- base: Image.Image,
- mask: Image.Image,
- color: tuple[int, int, int, int],
- blur: int,
- dx: int,
- dy: int,
-) -> None:
- shadow_mask = mask.filter(ImageFilter.GaussianBlur(blur))
- shifted = Image.new("L", base.size, 0)
- shifted.paste(shadow_mask, (dx, dy))
- composite_masked(base, Image.new("RGBA", base.size, color), shifted)
-
-
-def draw_outer_shell(canvas: Image.Image) -> None:
- bbox = sb((78, 58, 946, 934))
- radius = s(206)
- mask = rounded_mask(canvas.size, bbox, radius)
-
- add_shadow(canvas, mask, (55, 72, 98, 22), s(16), 0, s(16))
- add_shadow(canvas, mask, (255, 255, 255, 135), s(8), 0, s(-5))
-
- shell_fill = vertical_gradient(
- canvas.size,
- (253, 254, 255, 250),
- (228, 236, 246, 247),
- )
- composite_masked(canvas, shell_fill, mask)
-
- edge = ImageDraw.Draw(canvas)
- edge.rounded_rectangle(bbox, radius=radius, outline=(255, 255, 255, 226), width=s(7))
- edge.rounded_rectangle(
- sb((90, 70, 934, 922)),
- radius=radius - s(12),
- outline=(255, 255, 255, 85),
- width=s(2),
- )
- edge.rounded_rectangle(bbox, radius=radius, outline=(168, 184, 206, 32), width=s(2))
-
-
-def draw_capsule(
- canvas: Image.Image,
- bbox: tuple[int, int, int, int],
- top: tuple[int, int, int, int],
- bottom: tuple[int, int, int, int],
- outer_edge: tuple[int, int, int, int],
- shadow_color: tuple[int, int, int, int],
- shadow_blur: int,
- is_accent: bool = False,
-) -> None:
- radius = (bbox[2] - bbox[0]) // 2
- mask = rounded_mask(canvas.size, bbox, radius)
-
- add_shadow(canvas, mask, shadow_color, shadow_blur, 0, s(16 if not is_accent else 18))
- fill = vertical_gradient(canvas.size, top, bottom)
- composite_masked(canvas, fill, mask)
-
- draw = ImageDraw.Draw(canvas)
- draw.rounded_rectangle(bbox, radius=radius, outline=(255, 255, 255, 218), width=s(6))
- draw.rounded_rectangle(
- (bbox[0] + s(8), bbox[1] + s(8), bbox[2] - s(8), bbox[3] - s(8)),
- radius=radius - s(9),
- outline=(255, 255, 255, 102 if not is_accent else 76),
- width=s(2),
- )
- draw.rounded_rectangle(bbox, radius=radius, outline=outer_edge, width=s(2))
-
- if not is_accent:
- shade = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
- shade_draw = ImageDraw.Draw(shade)
- shade_draw.rounded_rectangle(
- (bbox[0] + s(7), bbox[1] + s(12), bbox[0] + s(30), bbox[3] - s(12)),
- radius=radius // 2,
- fill=(136, 158, 184, 22),
- )
- composite_masked(canvas, shade, mask)
-
-
-def draw_icon() -> Image.Image:
- canvas = Image.new("RGBA", (CANVAS_SIZE, CANVAS_SIZE), (0, 0, 0, 0))
- draw_outer_shell(canvas)
-
- left_bar = sb((300, 262, 420, 742))
- middle_bar = sb((452, 262, 572, 742))
- accent_bar = sb((604, 262, 724, 742))
-
- neutral_edge = (136, 158, 184, 58)
- draw_capsule(
- canvas,
- left_bar,
- (238, 245, 252, 226),
- (228, 237, 247, 226),
- neutral_edge,
- (136, 158, 184, 58),
- s(36),
- )
- draw_capsule(
- canvas,
- middle_bar,
- (232, 241, 249, 232),
- (222, 234, 246, 232),
- neutral_edge,
- (136, 158, 184, 66),
- s(38),
- )
- draw_capsule(
- canvas,
- accent_bar,
- (75, 183, 255, 238),
- (20, 145, 255, 238),
- (28, 133, 255, 72),
- (25, 118, 210, 62),
- s(20),
- is_accent=True,
- )
-
- return canvas.resize((BASE_SIZE, BASE_SIZE), Image.Resampling.LANCZOS)
-
-
-def write_contents_json() -> None:
- entries = [
- ("16x16", "1x", "icon_16x16.png"),
- ("16x16", "2x", "icon_16x16@2x.png"),
- ("32x32", "1x", "icon_32x32.png"),
- ("32x32", "2x", "icon_32x32@2x.png"),
- ("128x128", "1x", "icon_128x128.png"),
- ("128x128", "2x", "icon_128x128@2x.png"),
- ("256x256", "1x", "icon_256x256.png"),
- ("256x256", "2x", "icon_256x256@2x.png"),
- ("512x512", "1x", "icon_512x512.png"),
- ("512x512", "2x", "icon_512x512@2x.png"),
- ]
- payload = {
- "images": [
- {"filename": filename, "idiom": "mac", "scale": scale, "size": size}
- for size, scale, filename in entries
- ],
- "info": {"author": "xcode", "version": 1},
- }
- (APPICONSET / "Contents.json").write_text(json.dumps(payload, indent=2) + "\n")
-
-
-def write_icon_family(source: Image.Image) -> None:
- sizes = {
- "icon_16x16.png": 16,
- "icon_16x16@2x.png": 32,
- "icon_32x32.png": 32,
- "icon_32x32@2x.png": 64,
- "icon_128x128.png": 128,
- "icon_128x128@2x.png": 256,
- "icon_256x256.png": 256,
- "icon_256x256@2x.png": 512,
- "icon_512x512.png": 512,
- "icon_512x512@2x.png": 1024,
- }
-
+def sync_iconset() -> None:
APPICONSET.mkdir(parents=True, exist_ok=True)
- source.save(APPICONSET / "icon_1024_preview.png")
- for filename, size in sizes.items():
- source.resize((size, size), Image.Resampling.LANCZOS).save(APPICONSET / filename)
+ contents = load_contents_json()
+
+ for filename in ICON_FILES:
+ source = HANDOFF_ICONSET / filename
+ require_file(source)
+ shutil.copy2(source, APPICONSET / filename)
+
+ require_file(HANDOFF_PREVIEW)
+ shutil.copy2(HANDOFF_PREVIEW, APPICONSET / "icon_1024_preview.png")
+
+ with (APPICONSET / "Contents.json").open("w", encoding="utf-8") as file:
+ json.dump(contents, file, indent=2, ensure_ascii=False)
+ file.write("\n")
-def write_icns() -> None:
- iconset = RESOURCES / "AppIcon.iconset"
- if iconset.exists():
- shutil.rmtree(iconset)
- iconset.mkdir()
-
- for icon in APPICONSET.glob("icon_*.png"):
- if icon.name == "icon_1024_preview.png":
- continue
- shutil.copy2(icon, iconset / icon.name)
-
- subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(ICNS_PATH)], check=True)
- shutil.rmtree(iconset)
+def sync_icns() -> None:
+ require_file(HANDOFF_ICNS)
+ shutil.copy2(HANDOFF_ICNS, ICNS_PATH)
def main() -> None:
- icon = draw_icon()
- write_icon_family(icon)
- write_contents_json()
- write_icns()
+ sync_iconset()
+ sync_icns()
+ print(f"Synced AppIcon assets from: {HANDOFF_DIR}")
if __name__ == "__main__":
diff --git a/C3.tools/round1-app-column-alignment-fixture-qa.sh b/C3.tools/round1-app-column-alignment-fixture-qa.sh
index d2432dc..9dd6692 100755
--- a/C3.tools/round1-app-column-alignment-fixture-qa.sh
+++ b/C3.tools/round1-app-column-alignment-fixture-qa.sh
@@ -196,7 +196,7 @@
require(root.get("lastKeyboardCommand") == expected_key_sequence[-1], "last keyboard command must match the alignment target")
require(root.get("selectedAppGroupIndex") == target_app_index, "keyboard focus must select the target appGroupIndex")
require(item.get("isSelected") is True, "target App Shelf item must expose selected state")
- require("selected" in item.get("visualStates", []), "target App Shelf item must expose selected visual state")
+ require("selectedVisualSuppressed" in item.get("visualStates", []), "non-hovered keyboard-selected App Shelf item must suppress selected visual state")
cards = column.get("cards", [])
require(cards, "target Waterfall column must include cards")
require(root.get("selectedWindowID") == cards[0].get("windowID"), "right-arrow focus into target column must select its first window")
diff --git a/C3.tools/round1-app-shelf-fixture-qa.sh b/C3.tools/round1-app-shelf-fixture-qa.sh
index 59151e8..bd4716f 100755
--- a/C3.tools/round1-app-shelf-fixture-qa.sh
+++ b/C3.tools/round1-app-shelf-fixture-qa.sh
@@ -106,6 +106,8 @@
root = report.get("rootView", {})
items = root.get("appShelfItems", [])
+columns = root.get("waterfallColumns", [])
+segments = root.get("spaceLaneSegments", [])
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("appCount") == fixture_app_count, "fixture appCount must match requested count")
@@ -125,14 +127,41 @@
require(root.get("appShelfContentWidth", 0) <= root.get("appShelfVisibleWidth", 0), "non-scrollable fixture App Shelf content must fit visible width")
require(root.get("appShelfMaxScrollOffset", 0) == 0, "non-scrollable fixture App Shelf must expose zero max scroll offset")
require(root.get("appShelfHoveredIndex") == hover_index, "fixture App Shelf must expose requested hovered index")
+require(len(columns) == fixture_app_count, "Waterfall column reports must match fixture count")
require(sum(1 for item in items if item.get("isSelected") is True) == 1, "App Shelf must expose one selected app")
require(sum(1 for item in items if item.get("isHovered") is True) == 1, "App Shelf must expose one hovered app")
+require(sum(1 for column in columns if column.get("isHovered") is True) == 1, "Waterfall must expose one hovered column")
require(items[0].get("isSelected") is True, "fixture first app should be selected")
require(items[hover_index].get("isHovered") is True, "fixture requested app should be hovered")
-require("selected" in items[0].get("visualStates", []), "selected app must expose selected visual state")
+require(columns[hover_index].get("isHovered") is True, "fixture requested app column should be hovered")
+require("selectedVisualSuppressed" in items[0].get("visualStates", []), "non-hovered selected app must suppress selected visual state")
require("hover" in items[hover_index].get("visualStates", []), "hovered app must expose hover visual state")
-require(items[0].get("iconFrame", {}).get("width") == 68, "selected app icon must be 68pt")
+require("hover" in columns[hover_index].get("visualStates", []), "hovered column must expose hover visual state")
+require(items[0].get("iconFrame", {}).get("width") == expected_icon_size, "non-hovered selected app icon must match normal fixture icon size")
require(items[hover_index].get("iconFrame", {}).get("width") == 68, "hovered app icon must be 68pt")
+
+blue_reference = next(
+ (
+ color
+ for segment in segments
+ for color in segment.get("windowBlockColors", [])
+ if color.get("alpha", 0) > 0
+ ),
+ None
+)
+require(blue_reference is not None, "fixture must expose a Space Lane blue reference color")
+hovered_title_color = columns[hover_index].get("appNameColor", {})
+for channel in ("red", "green", "blue"):
+ require(
+ abs(hovered_title_color.get(channel, -1) - blue_reference.get(channel, -2)) <= 0.002,
+ f"hovered column title must use the same blue reference for {channel}"
+ )
+require(hovered_title_color.get("alpha", 0) >= 0.98, "hovered column title color must be opaque")
+for index, column in enumerate(columns):
+ if index == hover_index:
+ continue
+ require(column.get("isHovered") is False, "non-hovered columns must not be marked hovered")
+ require("hover" not in column.get("visualStates", []), "non-hovered columns must not expose hover visual state")
normal_items = [
item for item in items
@@ -148,7 +177,7 @@
selected_z = items[0].get("zPosition", 0)
hover_z = items[hover_index].get("zPosition", 0)
normal_z = normal_items[0].get("zPosition", 0)
-require(selected_z > hover_z, "selected App Shelf item must stay above non-selected hover item")
+require(selected_z == normal_z, "non-hovered selected App Shelf item must not float above normal items")
require(hover_z > normal_z, "hover App Shelf item must stay above normal items")
print(json.dumps({
@@ -158,6 +187,8 @@
"selectedIcon": items[0].get("iconFrame"),
"hoveredIndex": root.get("appShelfHoveredIndex"),
"hoveredIcon": items[hover_index].get("iconFrame"),
+ "hoveredColumnTitleColor": hovered_title_color,
+ "blueReference": blue_reference,
"selectedZ": selected_z,
"hoverZ": hover_z,
"scrollable": root.get("appShelfScrollable"),
diff --git a/C3.tools/round1-close-fixture-qa.sh b/C3.tools/round1-close-fixture-qa.sh
new file mode 100755
index 0000000..facf709
--- /dev/null
+++ b/C3.tools/round1-close-fixture-qa.sh
@@ -0,0 +1,547 @@
+#!/bin/bash
+# Round01 close interaction fixture QA. It restores the dedicated coverage for
+# Quick Switch close buttons, confirmation dialog, cancel flow, and App-level close.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+OUTPUT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+# shellcheck source=build-output-paths.sh
+source "$SCRIPT_DIR/build-output-paths.sh"
+APP="$BUILD_CURRENT_APP"
+BUNDLE_ID="com.ar.Aligner"
+CONFIRM_DEFAULTS_KEY="QuickSwitchConfirmBeforeClose"
+FIXTURE_APP_COUNT="${ALIGNER_ROUND1_CLOSE_FIXTURE_APP_COUNT:-4}"
+FIXTURE_WINDOWS_PER_APP="${ALIGNER_ROUND1_CLOSE_FIXTURE_WINDOWS_PER_APP:-3}"
+REPORT_WAIT="${ALIGNER_ROUND1_CLOSE_REPORT_WAIT:-6.0}"
+CARD_DEFAULT_HOVER_REPORT="$BUILD_REPORT_ROOT/round01-close-card-default-hover-report.json"
+CARD_HOVER_REPORT="$BUILD_REPORT_ROOT/round01-close-card-hover-report.json"
+CARD_CONFIRMATION_REPORT="$BUILD_REPORT_ROOT/round01-close-card-confirmation-report.json"
+CARD_CANCEL_REPORT="$BUILD_REPORT_ROOT/round01-close-card-cancel-report.json"
+CARD_CONFIRMED_REPORT="$BUILD_REPORT_ROOT/round01-close-card-confirmed-report.json"
+CARD_WINDOW_NOT_FOUND_REPORT="$BUILD_REPORT_ROOT/round01-close-card-window-not-found-report.json"
+CARD_OPTOUT_TOGGLE_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-toggle-report.json"
+CARD_OPTOUT_CONFIRMED_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-confirmed-report.json"
+CARD_OPTOUT_SECOND_REPORT="$BUILD_REPORT_ROOT/round01-close-card-optout-second-report.json"
+APP_CONFIRM_REPORT="$BUILD_REPORT_ROOT/round01-close-app-confirm-report.json"
+
+fail() {
+ echo "Round01 close fixture QA failed: $*" >&2
+ exit 1
+}
+
+aligner_pids_for_current_app() {
+ ps -axo pid=,args= | while read -r pid command; do
+ case "$command" in
+ "$APP/Contents/MacOS/Aligner"*) echo "$pid" ;;
+ esac
+ done
+}
+
+stop_current_aligner() {
+ for pid in $(aligner_pids_for_current_app); do
+ kill "$pid" 2>/dev/null || true
+ done
+
+ for _ in {1..30}; do
+ [ -z "$(aligner_pids_for_current_app)" ] && return
+ sleep 0.1
+ done
+
+ fail "current Aligner app did not exit before QA"
+}
+
+reset_close_confirmation_preference() {
+ defaults delete "$BUNDLE_ID" "$CONFIRM_DEFAULTS_KEY" >/dev/null 2>&1 || true
+}
+
+wait_for_report() {
+ local report="$1"
+ local mode="$2"
+ /usr/bin/python3 - "$report" "$REPORT_WAIT" "$mode" <<'PY'
+import json
+import sys
+import time
+
+path = sys.argv[1]
+timeout = float(sys.argv[2])
+mode = sys.argv[3]
+deadline = time.monotonic() + timeout
+last_report = None
+
+def ready(report):
+ root = report.get("rootView", {})
+ if report.get("snapshotLoaded") is not True:
+ return False
+ if mode == "visible":
+ return report.get("quickSwitchVisible") is True
+ if mode == "confirmed":
+ return report.get("quickSwitchVisible") is True and report.get("lastCloseResult") is not None
+ if mode == "verifiedFailure":
+ return (
+ report.get("quickSwitchVisible") is True
+ and report.get("lastCloseResult") is not None
+ and root.get("closeFeedbackKind") == "failure"
+ )
+ return False
+
+while time.monotonic() < deadline:
+ try:
+ with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+ last_report = report
+ if ready(report):
+ sys.exit(0)
+ except FileNotFoundError:
+ pass
+ except json.JSONDecodeError:
+ pass
+ time.sleep(0.2)
+
+if last_report is not None:
+ print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
+print(f"close fixture report {path} did not reach mode={mode} within {timeout:.1f}s", file=sys.stderr)
+sys.exit(1)
+PY
+}
+
+run_case() {
+ local report="$1"
+ local sequence="$2"
+ local mode="$3"
+ local preference_mode="${4:-reset}"
+ local close_result="${5:-requested}"
+
+ stop_current_aligner
+ if [ "$preference_mode" = "reset" ]; then
+ reset_close_confirmation_preference
+ fi
+ rm -f "$report"
+
+ ALIGNER_DEBUG_WINDOW_CLOSE_RESULT="$close_result" "$APP/Contents/MacOS/Aligner" \
+ --round0-skip-permissions \
+ --round01-open-quick-switch \
+ --round01-fixture-app-count="$FIXTURE_APP_COUNT" \
+ --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \
+ --round01-disable-screenshot-refresh \
+ --round01-debug-window-close \
+ --round01-debug-mouse-sequence="$sequence" \
+ --round01-quick-switch-report="$report" &
+ APP_PID=$!
+
+ cleanup() {
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ stop_current_aligner
+ }
+ trap cleanup EXIT
+
+ wait_for_report "$report" "$mode"
+ kill "$APP_PID" 2>/dev/null || true
+ wait "$APP_PID" 2>/dev/null || true
+ trap - EXIT
+ stop_current_aligner
+}
+
+assert_card_default_hover_report() {
+ /usr/bin/python3 - "$CARD_DEFAULT_HOVER_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+columns = root.get("waterfallColumns", [])
+card = columns[0]["cards"][0]
+glyph = card.get("closeButtonGlyphColor", {})
+fill = card.get("closeButtonFillColor", {})
+
+require(report.get("quickSwitchVisible") is True, "card hover must keep Quick Switch visible")
+require(root.get("hoveredCloseTargetKind") is None, "plain card hover must not hover the close target itself")
+require(card.get("closeButtonVisible") is True, "plain hovered card close button must be visible")
+require(card.get("closeButtonSize") == 18, "plain hovered card close button must remain compact")
+require(card.get("closeButtonGlyphLineWidth", 99) <= 1.5, "plain close glyph must use a lighter stroke")
+require(glyph.get("red", 0) >= 0.95 and glyph.get("green", 0) >= 0.95 and glyph.get("blue", 0) >= 0.95, "plain close glyph must be white, not dark")
+require(0.70 <= glyph.get("alpha", 0) <= 0.92, "plain close glyph must be translucent")
+require(fill.get("alpha", 1) <= 0.36, "plain close button fill must be subtle")
+print(json.dumps({
+ "case": "cardDefaultHover",
+ "closeButtonSize": card.get("closeButtonSize"),
+ "glyphColor": glyph,
+ "fillColor": fill,
+ "glyphLineWidth": card.get("closeButtonGlyphLineWidth")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_hover_report() {
+ /usr/bin/python3 - "$CARD_HOVER_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+columns = root.get("waterfallColumns", [])
+card = columns[0]["cards"][0]
+require(report.get("quickSwitchVisible") is True, "card hover must keep Quick Switch visible")
+require(root.get("hoveredCloseTargetKind") == "window", "card close hover must set hoveredCloseTargetKind=window")
+require(root.get("pendingCloseTargetKind") is None, "card close hover must not create pending close target")
+require(card.get("closeButtonVisible") is True, "hovered card close button must be visible")
+require(card.get("closeButtonSize") == 24, "hovered card close button must expand to 24pt")
+require(root.get("closeConfirmationVisible") is False, "card close hover must not show confirmation")
+print(json.dumps({
+ "case": "cardHover",
+ "hoveredCloseTargetKind": root.get("hoveredCloseTargetKind"),
+ "closeButtonSize": card.get("closeButtonSize")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_confirmation_report() {
+ /usr/bin/python3 - "$CARD_CONFIRMATION_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+require(report.get("quickSwitchVisible") is True, "card close confirmation must keep Quick Switch visible before confirm")
+require(root.get("pendingCloseTargetKind") == "window", "card close click must create pending window target")
+require(root.get("closeConfirmationVisible") is True, "card close click must show confirmation")
+require(root.get("closeConfirmationArrowEdge") in ("left", "right"), "confirmation must expose arrow edge")
+require(report.get("lastCloseResult") is None, "card close confirmation must not call service before confirm")
+print(json.dumps({
+ "case": "cardConfirmation",
+ "pendingCloseTargetKind": root.get("pendingCloseTargetKind"),
+ "arrowEdge": root.get("closeConfirmationArrowEdge")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_cancel_report() {
+ /usr/bin/python3 - "$CARD_CANCEL_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+require(report.get("quickSwitchVisible") is True, "cancel must keep Quick Switch visible")
+require(root.get("pendingCloseTargetKind") is None, "cancel must clear pending close target")
+require(root.get("closeConfirmationVisible") is False, "cancel must hide confirmation")
+require(report.get("lastCloseResult") is None, "cancel must not call close service")
+print(json.dumps({
+ "case": "cardCancel",
+ "quickSwitchVisible": report.get("quickSwitchVisible"),
+ "closeConfirmationVisible": root.get("closeConfirmationVisible")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_confirmed_report() {
+ /usr/bin/python3 - "$CARD_CONFIRMED_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+visible_window_ids = [
+ card.get("windowID")
+ for column in root.get("waterfallColumns", [])
+ for card in column.get("cards", [])
+]
+require(report.get("quickSwitchVisible") is True, "confirmed window close must keep Quick Switch visible")
+require(report.get("lastDismissReason") is None, "confirmed window close must not dismiss Quick Switch")
+require(report.get("lastCloseTargetKind") == "window", "confirmed window close must record window target")
+require(report.get("lastCloseWindowID") is not None, "confirmed window close must record target window")
+require(report.get("lastCloseResult") == "requested", "debug window close service must return requested")
+require(report.get("lastCloseWindowID") not in visible_window_ids, "confirmed window close must optimistically remove the stale card from Waterfall")
+require(report.get("suppressedCloseTargetCount") == 1, "confirmed window close must expose one suppressed close target")
+require(root.get("closeConfirmationVisible") is False, "confirmed window close must hide confirmation")
+require(root.get("closeFeedbackVisible") is True, "confirmed window close must show failure feedback when target remains")
+require(root.get("closeFeedbackKind") == "failure", "confirmed window close feedback must be failure")
+require(root.get("closeFeedbackMessage") == "已发送关闭请求,但窗口仍在列表中。", "confirmed window close feedback message must explain verified close miss")
+require(root.get("closeFeedbackTargetKind") == "window", "confirmed window close feedback must keep target kind")
+print(json.dumps({
+ "case": "cardConfirmed",
+ "quickSwitchVisible": report.get("quickSwitchVisible"),
+ "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+ "lastCloseResult": report.get("lastCloseResult"),
+ "lastDismissReason": report.get("lastDismissReason"),
+ "closeFeedbackKind": root.get("closeFeedbackKind"),
+ "closeFeedbackMessage": root.get("closeFeedbackMessage")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_window_not_found_report() {
+ /usr/bin/python3 - "$CARD_WINDOW_NOT_FOUND_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+visible_window_ids = [
+ card.get("windowID")
+ for column in root.get("waterfallColumns", [])
+ for card in column.get("cards", [])
+]
+require(report.get("quickSwitchVisible") is True, "windowNotFound close must keep Quick Switch visible")
+require(report.get("lastCloseTargetKind") == "window", "windowNotFound close must record window target")
+require(report.get("lastCloseResult") == "windowNotFound", "debug close service must return windowNotFound")
+require(report.get("lastCloseWindowID") not in visible_window_ids, "windowNotFound close must remove the stale card from Waterfall")
+require(report.get("suppressedCloseTargetCount") == 1, "windowNotFound close must expose one suppressed close target")
+require(root.get("closeFeedbackVisible") is False, "windowNotFound close must not show technical failure feedback")
+require(root.get("closeFeedbackMessage") is None, "windowNotFound close must not expose a technical message")
+print(json.dumps({
+ "case": "cardWindowNotFound",
+ "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+ "lastCloseResult": report.get("lastCloseResult"),
+ "closeFeedbackVisible": root.get("closeFeedbackVisible"),
+ "suppressedCloseTargetCount": report.get("suppressedCloseTargetCount")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_optout_toggle_report() {
+ /usr/bin/python3 - "$CARD_OPTOUT_TOGGLE_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+require(report.get("quickSwitchVisible") is True, "opt-out toggle must keep Quick Switch visible")
+require(report.get("lastCloseResult") is None, "opt-out toggle alone must not request close")
+require(root.get("pendingCloseTargetKind") == "window", "opt-out toggle must keep pending window close target")
+require(root.get("pendingCloseOptOut") is True, "opt-out toggle must show checked state")
+require(root.get("closeConfirmationVisible") is True, "opt-out toggle must keep confirmation visible")
+print(json.dumps({
+ "case": "cardOptOutToggle",
+ "pendingCloseTargetKind": root.get("pendingCloseTargetKind"),
+ "pendingCloseOptOut": root.get("pendingCloseOptOut"),
+ "closeConfirmationVisible": root.get("closeConfirmationVisible")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_confirmation_preference_disabled() {
+ local value
+ value="$(defaults read "$BUNDLE_ID" "$CONFIRM_DEFAULTS_KEY" 2>/dev/null || true)"
+ [ "$value" = "0" ] || fail "close confirmation preference should be disabled after opt-out, got '$value'"
+ echo "{\"case\":\"closePreferenceDisabled\",\"$CONFIRM_DEFAULTS_KEY\":$value}"
+}
+
+assert_card_optout_confirmed_report() {
+ /usr/bin/python3 - "$CARD_OPTOUT_CONFIRMED_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+visible_window_ids = [
+ card.get("windowID")
+ for column in root.get("waterfallColumns", [])
+ for card in column.get("cards", [])
+]
+require(report.get("quickSwitchVisible") is True, "opt-out confirmed close must keep Quick Switch visible")
+require(report.get("lastCloseTargetKind") == "window", "opt-out confirmed close must record window target")
+require(report.get("lastCloseResult") == "requested", "opt-out confirmed close must request close")
+require(report.get("lastCloseWindowID") not in visible_window_ids, "opt-out confirmed close must optimistically remove the stale card")
+require(report.get("suppressedCloseTargetCount") == 1, "opt-out confirmed close must expose one suppressed close target")
+require(root.get("closeConfirmationVisible") is False, "opt-out confirmed close must hide confirmation")
+require(root.get("pendingCloseOptOut") is False, "opt-out confirmed close must clear temporary checkbox state")
+require(root.get("closeFeedbackKind") == "failure", "opt-out confirmed close must still verify close result")
+print(json.dumps({
+ "case": "cardOptOutConfirmed",
+ "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+ "lastCloseResult": report.get("lastCloseResult"),
+ "pendingCloseOptOut": root.get("pendingCloseOptOut"),
+ "closeConfirmationVisible": root.get("closeConfirmationVisible")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_card_optout_second_report() {
+ /usr/bin/python3 - "$CARD_OPTOUT_SECOND_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+visible_window_ids = [
+ card.get("windowID")
+ for column in root.get("waterfallColumns", [])
+ for card in column.get("cards", [])
+]
+require(report.get("quickSwitchVisible") is True, "second close after opt-out must keep Quick Switch visible")
+require(report.get("lastCloseTargetKind") == "window", "second close after opt-out must directly request window close")
+require(report.get("lastCloseResult") == "requested", "second close after opt-out must call close service")
+require(report.get("lastCloseWindowID") not in visible_window_ids, "second close after opt-out must optimistically remove the stale card")
+require(report.get("suppressedCloseTargetCount") == 1, "second close after opt-out must expose one suppressed close target")
+require(root.get("pendingCloseTargetKind") is None, "second close after opt-out must not create pending close target")
+require(root.get("closeConfirmationVisible") is False, "second close after opt-out must not show confirmation")
+require(root.get("closeFeedbackKind") == "failure", "second close after opt-out must still verify close result")
+print(json.dumps({
+ "case": "cardOptOutSecondClose",
+ "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+ "lastCloseResult": report.get("lastCloseResult"),
+ "pendingCloseTargetKind": root.get("pendingCloseTargetKind"),
+ "closeConfirmationVisible": root.get("closeConfirmationVisible")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_app_confirm_report() {
+ /usr/bin/python3 - "$APP_CONFIRM_REPORT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+require(report.get("quickSwitchVisible") is True, "confirmed App close must keep Quick Switch visible")
+require(report.get("lastDismissReason") is None, "confirmed App close must not dismiss Quick Switch")
+require(report.get("lastCloseTargetKind") == "app", "confirmed App close must record app target")
+require(report.get("lastCloseAppGroupIndex") == 1, "confirmed App close must record target app group")
+require(report.get("lastCloseResult") == "requested", "debug App close service must return requested")
+require(report.get("suppressedCloseTargetCount") == 1, "confirmed App close must expose one suppressed close target")
+require(1 not in [column.get("appGroupIndex") for column in root.get("waterfallColumns", [])], "confirmed App close must optimistically remove the target column")
+require(root.get("closeConfirmationVisible") is False, "confirmed App close must hide confirmation")
+require(root.get("closeFeedbackVisible") is True, "confirmed App close must show failure feedback when target remains")
+require(root.get("closeFeedbackKind") == "failure", "confirmed App close feedback must be failure")
+require(root.get("closeFeedbackMessage") == "已发送关闭请求,但窗口仍在列表中。", "confirmed App close feedback message must explain verified close miss")
+require(root.get("closeFeedbackTargetKind") == "app", "confirmed App close feedback must keep target kind")
+print(json.dumps({
+ "case": "appConfirm",
+ "quickSwitchVisible": report.get("quickSwitchVisible"),
+ "lastCloseTargetKind": report.get("lastCloseTargetKind"),
+ "lastCloseResult": report.get("lastCloseResult"),
+ "lastDismissReason": report.get("lastDismissReason"),
+ "closeFeedbackKind": root.get("closeFeedbackKind"),
+ "closeFeedbackMessage": root.get("closeFeedbackMessage")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+stop_current_aligner
+"$SCRIPT_DIR/package-app.sh" >&2
+
+run_case "$CARD_DEFAULT_HOVER_REPORT" "hover-card:0:0" "visible"
+assert_card_default_hover_report
+
+run_case "$CARD_HOVER_REPORT" "hover-card-close:0:0" "visible"
+assert_card_hover_report
+
+run_case "$CARD_CONFIRMATION_REPORT" "click-card-close:0:0" "visible"
+assert_card_confirmation_report
+
+run_case "$CARD_CANCEL_REPORT" "click-card-close:0:0,cancel-close" "visible"
+assert_card_cancel_report
+
+run_case "$CARD_CONFIRMED_REPORT" "click-card-close:0:0,confirm-close" "verifiedFailure"
+assert_card_confirmed_report
+
+run_case "$CARD_WINDOW_NOT_FOUND_REPORT" "click-card-close:0:0,confirm-close" "confirmed" "reset" "windowNotFound"
+assert_card_window_not_found_report
+
+run_case "$CARD_OPTOUT_TOGGLE_REPORT" "click-card-close:0:0,toggle-close-optout" "visible"
+assert_card_optout_toggle_report
+assert_confirmation_preference_disabled
+
+run_case "$CARD_OPTOUT_CONFIRMED_REPORT" "click-card-close:0:0,toggle-close-optout,confirm-close" "verifiedFailure"
+assert_card_optout_confirmed_report
+assert_confirmation_preference_disabled
+
+run_case "$CARD_OPTOUT_SECOND_REPORT" "click-card-close:0:1" "verifiedFailure" "preserve"
+assert_card_optout_second_report
+
+run_case "$APP_CONFIRM_REPORT" "click-app-close:1,confirm-close" "verifiedFailure"
+assert_app_confirm_report
+
+echo "Round01 close fixture QA passed."
diff --git a/C3.tools/round1-keyboard-navigation-fixture-qa.sh b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
index 3254558..8dc7de2 100755
--- a/C3.tools/round1-keyboard-navigation-fixture-qa.sh
+++ b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
@@ -135,9 +135,9 @@
selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card after keyboard navigation")
require(selected_cards[0].get("windowID") == expected_selected_window_id, "selected Waterfall card must match selectedWindowID")
-require("selected" in selected_cards[0].get("visualStates", []), "selected Waterfall card must expose selected visual state")
-require(selected_cards[0].get("shineVisible") is True, "selected Waterfall card must expose shine")
-require(selected_cards[0].get("zPosition", 0) > 0, "selected Waterfall card must float above normal cards")
+require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "non-hovered keyboard-selected Waterfall card must suppress selected visual state")
+require(selected_cards[0].get("shineVisible") is False, "non-hovered keyboard-selected Waterfall card must not expose shine")
+require(selected_cards[0].get("zPosition", 0) == 0, "non-hovered keyboard-selected Waterfall card must not float above normal cards")
selected_items = [item for item in items if item.get("isSelected") is True]
require(len(selected_items) == 1, "App Shelf must expose exactly one selected App")
diff --git a/C3.tools/round1-mouse-interaction-fixture-qa.sh b/C3.tools/round1-mouse-interaction-fixture-qa.sh
index ce9793e..c8c885f 100755
--- a/C3.tools/round1-mouse-interaction-fixture-qa.sh
+++ b/C3.tools/round1-mouse-interaction-fixture-qa.sh
@@ -18,7 +18,7 @@
MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_SEQUENCE:-hover-card:1:7,hover-app:5,hover-card:7:0,click-card:7:0}"
APP_ONLY_MOUSE_SEQUENCE="${ALIGNER_ROUND1_MOUSE_APP_ONLY_SEQUENCE:-hover-app:5,click-app:7}"
REPORT_WAIT="${ALIGNER_ROUND1_MOUSE_REPORT_WAIT:-6.0}"
-WINDOW_WAIT="${ALIGNER_ROUND1_MOUSE_WINDOW_WAIT:-3.0}"
+WINDOW_WAIT="${ALIGNER_ROUND1_MOUSE_WINDOW_WAIT:-6.0}"
fail() {
echo "Round01 mouse interaction fixture QA failed: $*" >&2
@@ -288,9 +288,18 @@
require(hovered_items[0].get("index") == expected_app_group_index, "hovered window card must link to App Shelf")
hovered_segments = [segment for segment in segments if "hover" in segment.get("visualStates", [])]
-require(len(hovered_segments) == 1, "hovered window card must link to exactly one Space Lane segment")
-require(hovered_segments[0].get("windowCount", 0) > 0, "hovered Space Lane segment must be a real space with windows")
-require(root.get("hoveredSpaceID") == expected_space_id, "root hoveredSpaceID must match clicked card primarySpaceID")
+space_focused_segments = [segment for segment in segments if "spaceFocus" in segment.get("visualStates", [])]
+window_associated_segments = [segment for segment in segments if segment.get("isWindowAssociated") is True]
+require(root.get("hoverTargetKind") == "window", "hovered Waterfall card must report window hover target")
+require(root.get("activeSpaceFocusID") is None, "hovered Waterfall card must not create Space Lane focus")
+require(len(hovered_segments) == 0, "hovered Waterfall card must not mark a Space Lane segment as hovered")
+require(len(space_focused_segments) == 0, "hovered Waterfall card must not mark a Space Lane segment as focused")
+require(root.get("hoveredSpaceID") == expected_space_id, "root hoveredSpaceID may still mirror clicked card primarySpaceID for window context")
+require(root.get("windowHoverAssociatedSpaceID") == expected_space_id, "hovered Waterfall card must expose its Space as windowHoverAssociatedSpaceID")
+require(len(window_associated_segments) == 1, "hovered Waterfall card must highlight exactly one associated Space segment")
+require(window_associated_segments[0].get("spaceID") == expected_space_id, "window-associated Space segment must match hovered card Space")
+require("windowAssociated" in window_associated_segments[0].get("visualStates", []), "window-associated Space segment must expose windowAssociated")
+require("focused" not in window_associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose focused")
frame = selected_cards[0].get("visibleFrame", {})
require(frame.get("x", -1) >= 0, "clicked Waterfall card must be horizontally visible")
@@ -304,7 +313,9 @@
"lastActivationResult": report.get("lastActivationResult"),
"waterfallScrollOffset": root.get("waterfallScrollOffset"),
"hoveredSpaceID": root.get("hoveredSpaceID"),
- "hoveredSpaceLabel": hovered_segments[0].get("label")
+ "windowHoverAssociatedSpaceID": root.get("windowHoverAssociatedSpaceID"),
+ "hoverTargetKind": root.get("hoverTargetKind"),
+ "activeSpaceFocusID": root.get("activeSpaceFocusID")
}, indent=2, ensure_ascii=False))
PY
}
diff --git a/C3.tools/round1-session-snapshot-qa.sh b/C3.tools/round1-session-snapshot-qa.sh
index 23be5db..981dd57 100755
--- a/C3.tools/round1-session-snapshot-qa.sh
+++ b/C3.tools/round1-session-snapshot-qa.sh
@@ -58,6 +58,15 @@
def near(lhs, rhs, tolerance=0.01):
return abs((lhs or 0) - (rhs or 0)) <= tolerance
+def is_blue(color, min_alpha=0.30):
+ return (
+ isinstance(color, dict)
+ and color.get("blue", 0) >= 0.85
+ and color.get("green", 0) >= 0.35
+ and color.get("red", 1) <= 0.18
+ and color.get("alpha", 0) >= min_alpha
+ )
+
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("displayCount", 0) >= 1, "displayCount must be >= 1")
require(report.get("spaceCount", 0) >= 1, "spaceCount must be >= 1")
@@ -101,6 +110,30 @@
require(all(isinstance(segment.get("appNames"), list) for segment in lane_segments), "Space Lane segments must expose app names")
require(all(isinstance(segment.get("frame"), dict) for segment in lane_segments), "Space Lane segments must expose content frames")
require(all(isinstance(segment.get("visibleFrame"), dict) for segment in lane_segments), "Space Lane segments must expose visible frames")
+require(all(isinstance(segment.get("borderColor"), dict) for segment in lane_segments), "Space Lane segments must expose border colors")
+require(all(isinstance(segment.get("windowBlockColors"), list) for segment in lane_segments), "Space Lane segments must expose window block colors")
+occupied_segments = [segment for segment in lane_segments if segment.get("windowCount", 0) > 0]
+empty_segments = [segment for segment in lane_segments if segment.get("windowCount", 0) == 0]
+require(all(is_blue(segment.get("borderColor"), min_alpha=0.34) for segment in occupied_segments), "occupied Space segments must use blue borders")
+require(all(not is_blue(segment.get("borderColor"), min_alpha=0.34) for segment in empty_segments), "empty Space segments must not use blue borders")
+occupied_floating_segments = [segment for segment in occupied_segments if segment.get("type") != "fullscreen"]
+require(all(segment.get("windowBlockCount", 0) > 0 for segment in occupied_floating_segments), "occupied non-fullscreen Spaces must expose window blocks")
+for segment in occupied_floating_segments:
+ require(all(is_blue(color, min_alpha=0.50) for color in segment.get("windowBlockColors", [])), "occupied Space window blocks must be blue")
+same_display_gaps = []
+cross_display_gaps = []
+for previous, current in zip(lane_segments, lane_segments[1:]):
+ previous_frame = previous.get("visibleFrame", {})
+ current_frame = current.get("visibleFrame", {})
+ gap = current_frame.get("x", 0) - (previous_frame.get("x", 0) + previous_frame.get("width", 0))
+ if previous.get("displayUUID") == current.get("displayUUID"):
+ same_display_gaps.append(gap)
+ else:
+ cross_display_gaps.append(gap)
+if cross_display_gaps:
+ require(all(gap >= 24 for gap in cross_display_gaps), "Space Lane must leave a visible gap between displays")
+ if same_display_gaps:
+ require(min(cross_display_gaps) > max(same_display_gaps) + 8, "display gap must be clearly larger than same-display Space gaps")
current_labels = root_view.get("spaceLaneCurrentLabels", [])
fullscreen_labels = root_view.get("spaceLaneFullscreenLabels", [])
require(len(current_labels) >= 1, "Space Lane must mark at least one current Space")
@@ -110,8 +143,19 @@
fullscreen_segments = [segment for segment in lane_segments if segment.get("type") == "fullscreen"]
require([segment.get("label") for segment in fullscreen_segments] == fullscreen_labels, "fullscreen segment reports must match fullscreen labels")
require(all(segment.get("fullscreenMarkerVisible") is True for segment in fullscreen_segments), "fullscreen segments must expose fullscreen marker")
+require(all(segment.get("fullscreenMarkerKind") == "cornerBracket" for segment in fullscreen_segments), "fullscreen segments must use corner bracket marker")
+require(all(segment.get("windowBlockCount") == 0 for segment in fullscreen_segments), "fullscreen segments must not expose floating-window blocks")
+non_fullscreen_segments = [segment for segment in lane_segments if segment.get("type") != "fullscreen"]
+require(all(segment.get("fullscreenMarkerKind") == "none" for segment in non_fullscreen_segments), "non-fullscreen segments must not expose fullscreen marker")
for segment in fullscreen_segments:
require("fullscreen" in segment.get("visualStates", []), "fullscreen segments must expose fullscreen visual state")
+ marker_frame = segment.get("fullscreenMarkerFrame", {})
+ app_frame = segment.get("appFrame", {})
+ require(isinstance(marker_frame, dict), "fullscreen segments must expose marker frame")
+ require(isinstance(app_frame, dict), "fullscreen segments must expose centered App label frame")
+ marker_mid_y = marker_frame.get("y", 0) + marker_frame.get("height", 0) / 2
+ app_mid_y = app_frame.get("y", 0) + app_frame.get("height", 0) / 2
+ require(abs(marker_mid_y - app_mid_y) <= 2, "fullscreen App label must be vertically centered in marker")
for segment in lane_segments:
if segment.get("isCurrent") is True:
require("current" in segment.get("visualStates", []), "current segment must expose current visual state")
@@ -134,6 +178,12 @@
require(lane_max_scroll_offset > 0, "overflowing Space Lane must expose positive max scroll offset")
else:
require(root_view.get("spaceLaneScrollable") is False, "non-overflowing Space Lane should not report scrollable")
+ if lane_segments:
+ first_frame = lane_segments[0].get("visibleFrame", {})
+ last_frame = lane_segments[-1].get("visibleFrame", {})
+ left_blank = first_frame.get("x", 0)
+ right_blank = lane_visible_width - (last_frame.get("x", 0) + last_frame.get("width", 0))
+ require(abs(left_blank - right_blank) <= 2, "non-overflowing Space Lane segments must be centered")
app_shelf_items = root_view.get("appShelfItems", [])
app_shelf_names = root_view.get("appShelfNames", [])
require(root_view.get("appShelfItemCount") == report.get("appCount"), "App Shelf item count must match appCount")
@@ -153,12 +203,12 @@
require(all(isinstance(item.get("frame"), dict) for item in app_shelf_items), "App Shelf items must expose frames")
require(all(isinstance(item.get("iconFrame"), dict) for item in app_shelf_items), "App Shelf items must expose icon frames")
for item in app_shelf_items:
- if item.get("isSelected") is True:
- expected_icon_size = root_view.get("appShelfSelectedIconSize")
- elif item.get("isHovered") is True:
+ if item.get("isHovered") is True:
expected_icon_size = root_view.get("appShelfHoverIconSize")
else:
expected_icon_size = icon_size
+ if item.get("isSelected") is True and item.get("isHovered") is not True:
+ require("selectedVisualSuppressed" in item.get("visualStates", []), "non-hovered selected App Shelf item must suppress selected visual state")
require(abs(item.get("iconFrame", {}).get("width", -1) - expected_icon_size) < 0.01, "App Shelf icon frame width must match visual icon size")
require(abs(item.get("iconFrame", {}).get("height", -1) - expected_icon_size) < 0.01, "App Shelf icon frame height must match visual icon size")
row_y_values = {round(item.get("frame", {}).get("y", 0), 2) for item in app_shelf_items}
@@ -239,6 +289,14 @@
if card.get("screenshotSource") == "notRequested":
require(card.get("screenshotNotRequestedReason") is not None, "not-requested screenshot cards must expose reason")
require(isinstance(card.get("thumbnailFrame"), dict), "Waterfall cards must expose thumbnail frames")
+ require(isinstance(card.get("titleBarFrame"), dict), "Waterfall cards must expose title bar frames")
+ require(card.get("titleBarBorderWidth") == 0, "Waterfall title area must not draw an enclosing rounded rectangle")
+ require(isinstance(card.get("titleFrame"), dict), "Waterfall cards must expose title frames")
+ card_frame = card.get("frame", {})
+ thumbnail_frame = card.get("thumbnailFrame", {})
+ title_bar_frame = card.get("titleBarFrame", {})
+ require(thumbnail_frame.get("width", 0) >= card_frame.get("width", 0) * 0.82, "Waterfall thumbnail must use most of the card width")
+ require(title_bar_frame.get("y", 0) > thumbnail_frame.get("y", 0) + thumbnail_frame.get("height", 0), "Waterfall title bar must sit above the thumbnail")
require(isinstance(card.get("appIconFrame"), dict), "Waterfall cards must expose App icon frames")
require(isinstance(card.get("visualStates"), list), "Waterfall cards must expose visual states")
require(card.get("titleTruncationMode") == "middle", "Waterfall card titles must use middle truncation")
@@ -257,6 +315,11 @@
require(root_view.get("screenshotResolvedCount") + root_view.get("screenshotPendingCount") == root_view.get("screenshotEligibleCount"), "resolved + pending screenshots must match eligible count")
require(root_view.get("screenshotEligibleCount") + root_view.get("screenshotNotRequestedCount") == waterfall_card_count, "eligible + not-requested screenshots must cover all cards")
require(global_indexes == sorted(global_indexes), "Waterfall global indexes must be monotonic across columns")
+if root_view.get("activeSpaceFocusID") is None:
+ require(
+ all("spaceFocused" not in card.get("visualStates", []) for column in waterfall_columns for card in column.get("cards", [])),
+ "Waterfall cards must not expose spaceFocused when no Space Lane focus is active"
+ )
selected_cards = [
card
for column in waterfall_columns
@@ -264,9 +327,9 @@
if card.get("isSelected") is True
]
require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card")
-require("selected" in selected_cards[0].get("visualStates", []), "selected Waterfall card must expose selected visual state")
-require(selected_cards[0].get("shineVisible") is True, "selected Waterfall card must expose shine layer")
-require(selected_cards[0].get("zPosition", 0) > 0, "selected Waterfall card must be above normal cards")
+require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "non-hovered selected Waterfall card must suppress selected visual state")
+require(selected_cards[0].get("shineVisible") is False, "non-hovered selected Waterfall card must not expose shine layer")
+require(selected_cards[0].get("zPosition", 0) == 0, "non-hovered selected Waterfall card must not float above normal cards")
require(isinstance(report.get("overlayOpenElapsedMilliseconds"), (int, float)), "overlay open timing must exist")
require(isinstance(report.get("snapshotStartElapsedMilliseconds"), (int, float)), "snapshot start timing must exist")
require(report["snapshotStartElapsedMilliseconds"] >= report["overlayOpenElapsedMilliseconds"], "snapshot must start after overlay open path")
diff --git a/C3.tools/round1-three-zone-linkage-fixture-qa.sh b/C3.tools/round1-three-zone-linkage-fixture-qa.sh
index 4fcd850..93a8bc2 100755
--- a/C3.tools/round1-three-zone-linkage-fixture-qa.sh
+++ b/C3.tools/round1-three-zone-linkage-fixture-qa.sh
@@ -122,26 +122,34 @@
local app_count="${4:-$FIXTURE_APP_COUNT}"
local windows_per_app="${5:-$FIXTURE_WINDOWS_PER_APP}"
local quick_switch_expectation="${6:-visible}"
+ local fixture_shape="${7:-single-display}"
+ local args=(
+ --round0-skip-permissions
+ --round01-open-quick-switch
+ --round01-fixture-app-count="$app_count"
+ --round01-fixture-windows-per-app="$windows_per_app"
+ --round01-debug-overlay-width="$OVERLAY_WIDTH"
+ --round01-disable-screenshot-refresh
+ --round01-debug-window-activation
+ --round01-debug-mouse-sequence="$sequence"
+ --round01-quick-switch-report="$report"
+ )
+ if [ "$fixture_shape" = "multi-display" ]; then
+ args+=(--round01-fixture-multi-display)
+ fi
rm -f "$report"
- "$APP/Contents/MacOS/Aligner" \
- --round0-skip-permissions \
- --round01-open-quick-switch \
- --round01-fixture-app-count="$app_count" \
- --round01-fixture-windows-per-app="$windows_per_app" \
- --round01-debug-overlay-width="$OVERLAY_WIDTH" \
- --round01-disable-screenshot-refresh \
- --round01-debug-window-activation \
- --round01-debug-mouse-sequence="$sequence" \
- --round01-quick-switch-report="$report" &
+ "$APP/Contents/MacOS/Aligner" "${args[@]}" &
APP_PID=$!
wait_for_report "$report" "$expected_last_mouse"
if [ "$quick_switch_expectation" = "hidden" ]; then
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
- else
+ elif [ "$quick_switch_expectation" = "visible" ]; then
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-quick-switch >&2
+ elif [ "$quick_switch_expectation" != "report-only" ]; then
+ fail "unknown quick switch expectation: $quick_switch_expectation"
fi
kill "$APP_PID" 2>/dev/null || true
@@ -186,10 +194,16 @@
require(len(focused_segments) == 1, "exactly one Space Lane segment must be focused")
require(focused_segments[0].get("label") == "01", "focused Space should be 01")
require("focused" in focused_segments[0].get("visualStates", []), "focused Space segment must expose focused visual state")
+require(focused_segments[0].get("shadowRadius", 0) >= 24, "focused Space segment shadow radius must use the stronger float treatment")
+require(focused_segments[0].get("shadowOpacity", 0) >= 0.25, "focused Space segment shadow opacity must use the stronger float treatment")
require(len(items) == fixture_app_count, "App Shelf fixture item count must match app count")
require(all(item.get("isSpaceFocused") is True for item in items), "all fixture apps must be highlighted for Space A1")
require(all("spaceFocused" in item.get("visualStates", []) for item in items), "App Shelf items must expose spaceFocused visual state")
+selected_items = [item for item in items if item.get("isSelected") is True]
+require(len(selected_items) == 1, "App Shelf must keep one functional selection while Space focus is active")
+require("selected" not in selected_items[0].get("visualStates", []), "Space focus must suppress App Shelf selected visual state")
+require("selectedVisualSuppressed" in selected_items[0].get("visualStates", []), "suppressed App Shelf selected visual must be explicit")
require(all(item.get("spaceFocusWindowCount", 0) > 0 for item in items), "focused Space badge count must be positive for every fixture app")
require(all(item.get("badgeVisible") is True for item in items), "focused Space badges must be visible")
require(items[0].get("spaceFocusDirection") == "visible", "first related App must be visible")
@@ -201,10 +215,28 @@
require(columns[0].get("spaceFocusDirection") == "visible", "first Waterfall column must be visible")
require(columns[-1].get("isSpaceFocused") is True, "last Waterfall column must be marked focused for Space")
require(columns[-1].get("spaceFocusDirection") == "right", "last Waterfall column must report right direction before remote click")
+selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
+require(len(selected_cards) == 1, "Waterfall must keep one functional selection while Space focus is active")
+require("selected" not in selected_cards[0].get("visualStates", []), "Space focus must suppress Waterfall selected visual state")
+require("selectedVisualSuppressed" in selected_cards[0].get("visualStates", []), "suppressed Waterfall selected visual must be explicit")
+require(selected_cards[0].get("shadowRadius", 0) >= 24, "space-focused Waterfall card shadow radius must use the stronger float treatment")
+require(selected_cards[0].get("shadowOpacity", 0) >= 0.19, "space-focused Waterfall card shadow opacity must use the stronger float treatment")
+
+space_fill = focused_segments[0].get("backgroundColor", {})
+card_fill = selected_cards[0].get("backgroundColor", {})
+for channel in ("red", "green", "blue", "alpha"):
+ require(
+ abs(space_fill.get(channel, -1) - card_fill.get(channel, -2)) <= 0.002,
+ f"focused Space fill must match space-focused card fill for {channel}"
+ )
print(json.dumps({
"activeSpaceFocusID": root.get("activeSpaceFocusID"),
"focusedApps": root.get("spaceFocusAppGroupIndexes"),
+ "focusedSpaceFill": space_fill,
+ "spaceFocusedCardFill": card_fill,
+ "focusedSpaceShadowRadius": focused_segments[0].get("shadowRadius"),
+ "spaceFocusedCardShadowRadius": selected_cards[0].get("shadowRadius"),
"lastRemoteDirection": items[-1].get("spaceFocusDirection"),
"lastRemoteBadgeText": items[-1].get("badgeText")
}, indent=2, ensure_ascii=False))
@@ -234,7 +266,8 @@
last_index = fixture_app_count - 1
require(report.get("quickSwitchVisible") is False, "remote App click must close Quick Switch after committing its first window")
-require(root.get("activeSpaceFocusID") == 1, "Space hover preview should still be reflected in the pre-commit report")
+require(root.get("activeSpaceFocusID") is None, "App click must clear Space hover focus before the pre-commit report")
+require(root.get("hoverTargetKind") == "app", "App click must leave the hover target on the clicked App, not on Space")
require(root.get("lastClickedAppGroupIndex") == last_index, "remote App click must be recorded")
require(root.get("lastAppShelfClickChangedSelection") is True, "App Shelf click must move keyboard selection to the remote App column")
require(root.get("selectedAppGroupIndex") == last_index, "remote App click must focus the remote App column")
@@ -245,12 +278,14 @@
require(report.get("lastActivationResult") == "activated", "debug activation must activate the first remote window")
require(report.get("lastCommitSource") == "mouse", "remote App commit source must be mouse")
require(root.get("waterfallScrollOffset", 0) > 0, "remote App click must scroll Waterfall horizontally")
-require(items[last_index].get("spaceFocusDirection") == "visible", "remote App must become visible after click")
-require(columns[last_index].get("spaceFocusDirection") == "visible", "remote Waterfall column must become visible after click")
-require(items[last_index].get("badgeVisible") is True, "focused Space badge must remain visible after remote click")
+require(items[last_index].get("spaceFocusDirection") == "none", "App click must clear Space focus direction after leaving Space Lane")
+require(columns[last_index].get("spaceFocusDirection") == "none", "Waterfall column must not keep Space focus after App click")
+require(all(item.get("isSpaceFocused") is False for item in items), "App click must clear all App Shelf spaceFocused states")
+require(all(column.get("isSpaceFocused") is False for column in columns), "App click must clear all Waterfall column spaceFocused states")
print(json.dumps({
"activeSpaceFocusID": root.get("activeSpaceFocusID"),
+ "hoverTargetKind": root.get("hoverTargetKind"),
"lastClickedAppGroupIndex": root.get("lastClickedAppGroupIndex"),
"lastCommittedWindowID": root.get("lastCommittedWindowID"),
"lastActivationResult": report.get("lastActivationResult"),
@@ -307,6 +342,128 @@
PY
}
+assert_space_focus_clear_report() {
+ /usr/bin/python3 - "$1" "$FIXTURE_APP_COUNT" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+fixture_app_count = int(sys.argv[2])
+
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+items = root.get("appShelfItems", [])
+columns = root.get("waterfallColumns", [])
+segments = root.get("spaceLaneSegments", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+hovered_window = next(
+ (
+ card
+ for column in columns
+ for card in column.get("cards", [])
+ if card.get("windowID") == root.get("hoveredWindowID")
+ ),
+ None
+)
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(root.get("hoverTargetKind") == "window", "hover-card after hover-space must set the hover target to window")
+require(root.get("activeSpaceFocusID") is None, "hover-card must clear activeSpaceFocusID after leaving Space Lane")
+require(root.get("spaceLaneHoveredSpaceID") is None, "hover-card must clear Space Lane hovered Space ID")
+require(root.get("spaceFocusAppGroupIndexes") == [], "hover-card must clear all Space focused App indexes")
+require(root.get("hoveredWindowID") is not None, "hover-card must still expose hoveredWindowID")
+require(hovered_window is not None, "hover-card report must contain the hovered window card")
+require(root.get("windowHoverAssociatedSpaceID") == hovered_window.get("primarySpaceID"), "hover-card must expose the hovered window Space ID")
+require(len(items) == fixture_app_count, "App Shelf fixture item count must match app count")
+require(all(item.get("isSpaceFocused") is False for item in items), "hover-card must not leave App Shelf items spaceFocused")
+require(all(column.get("isSpaceFocused") is False for column in columns), "hover-card must not leave Waterfall columns spaceFocused")
+require(all("spaceFocused" not in card.get("visualStates", []) for card in cards), "hover-card must not leave Waterfall cards spaceFocused")
+associated_segments = [segment for segment in segments if segment.get("isWindowAssociated") is True]
+require(len(associated_segments) == 1, "hover-card must highlight exactly one associated Space segment")
+require(associated_segments[0].get("spaceID") == hovered_window.get("primarySpaceID"), "window-associated Space segment must match hovered card Space")
+require("windowAssociated" in associated_segments[0].get("visualStates", []), "window-associated Space segment must expose windowAssociated visual state")
+require("focused" not in associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose focused")
+require("hover" not in associated_segments[0].get("visualStates", []), "window-associated Space segment must not expose Space hover")
+
+print(json.dumps({
+ "hoverTargetKind": root.get("hoverTargetKind"),
+ "activeSpaceFocusID": root.get("activeSpaceFocusID"),
+ "hoveredWindowID": root.get("hoveredWindowID"),
+ "windowHoverAssociatedSpaceID": root.get("windowHoverAssociatedSpaceID"),
+ "spaceFocusAppGroupIndexes": root.get("spaceFocusAppGroupIndexes")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
+assert_app_hover_space_lane_report() {
+ /usr/bin/python3 - "$1" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+root = report.get("rootView", {})
+items = root.get("appShelfItems", [])
+columns = root.get("waterfallColumns", [])
+segments = root.get("spaceLaneSegments", [])
+cards = [card for column in columns for card in column.get("cards", [])]
+
+target_column = next((column for column in columns if column.get("appGroupIndex") == 0), None)
+require(target_column is not None, "fixture must include App group 0")
+
+expected_space_ids = sorted({
+ card.get("primarySpaceID")
+ for card in target_column.get("cards", [])
+ if isinstance(card.get("primarySpaceID"), int)
+})
+reported_space_ids = sorted(root.get("appHoverAssociatedSpaceIDs", []))
+associated_segments = [segment for segment in segments if segment.get("isAppAssociated") is True]
+associated_segment_ids = sorted(segment.get("spaceID") for segment in associated_segments)
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(root.get("hoverTargetKind") == "app", "hover-app must set the hover target to app")
+require(root.get("hoveredAppGroupIndex") == 0, "hover-app must expose the hovered App group")
+require(root.get("activeSpaceFocusID") is None, "hover-app must not set activeSpaceFocusID")
+require(root.get("spaceLaneHoveredSpaceID") is None, "hover-app must not masquerade as Space Lane hover")
+require(root.get("spaceFocusAppGroupIndexes") == [], "hover-app must not activate Space focus")
+require(reported_space_ids == expected_space_ids, "hover-app must report only the hovered App's Spaces")
+require(associated_segment_ids == expected_space_ids, "Space Lane appAssociated segments must match hovered App Spaces")
+require(len(associated_segments) > 0, "hover-app must visually associate at least one Space")
+require(all("appAssociated" in segment.get("visualStates", []) for segment in associated_segments), "associated Space segments must expose appAssociated")
+require(all("focused" not in segment.get("visualStates", []) for segment in associated_segments), "app-associated Spaces must not expose focused")
+require(all("hover" not in segment.get("visualStates", []) for segment in associated_segments), "app-associated Spaces must not expose Space hover")
+require(all(segment.get("shadowRadius", 0) >= 18 for segment in associated_segments), "app-associated Spaces must get a visible float shadow")
+require(all(item.get("isSpaceFocused") is False for item in items), "hover-app must not mark App Shelf items spaceFocused")
+require(all(column.get("isSpaceFocused") is False for column in columns), "hover-app must not mark Waterfall columns spaceFocused")
+require(all("spaceFocused" not in card.get("visualStates", []) for card in cards), "hover-app must not mark Waterfall cards spaceFocused")
+
+print(json.dumps({
+ "hoverTargetKind": root.get("hoverTargetKind"),
+ "hoveredAppGroupIndex": root.get("hoveredAppGroupIndex"),
+ "appHoverAssociatedSpaceIDs": reported_space_ids,
+ "associatedSegmentIDs": associated_segment_ids,
+ "activeSpaceFocusID": root.get("activeSpaceFocusID")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
assert_app_shelf_scroll_report() {
/usr/bin/python3 - "$1" "$1" <<'PY'
import json
@@ -347,6 +504,96 @@
PY
}
+assert_display_group_report() {
+ /usr/bin/python3 - "$1" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+
+with open(path, "r", encoding="utf-8") as file:
+ report = json.load(file)
+
+def require(condition, message):
+ if not condition:
+ print(message, file=sys.stderr)
+ print(json.dumps(report, indent=2, ensure_ascii=False), file=sys.stderr)
+ sys.exit(1)
+
+def right_edge(frame):
+ return frame.get("x", 0) + frame.get("width", 0)
+
+def contains(outer, inner, tolerance=1.0):
+ return (
+ inner.get("x", 0) >= outer.get("x", 0) - tolerance
+ and inner.get("y", 0) >= outer.get("y", 0) - tolerance
+ and right_edge(inner) <= right_edge(outer) + tolerance
+ and inner.get("y", 0) + inner.get("height", 0) <= outer.get("y", 0) + outer.get("height", 0) + tolerance
+ )
+
+root = report.get("rootView", {})
+groups = root.get("spaceLaneDisplayGroups", [])
+segments = root.get("spaceLaneSegments", [])
+
+require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
+require(report.get("quickSwitchVisible") is True, "multi-display report must keep Quick Switch visible")
+require(root.get("activeSpaceFocusID") == 4, "multi-display hover must focus Space B1")
+require(root.get("spaceLaneDisplayGroupCount") == 2, "multi-display fixture must expose two Display groups")
+require(len(groups) == 2, "Display group reports must contain two groups")
+require(root.get("spaceLaneSegmentCount") == 5, "multi-display fixture must expose five Space segments")
+require(len(segments) == 5, "Space segment reports must contain five segments")
+require(groups[0].get("spaceLabels") == ["A1", "A2", "A3"], "first Display group must contain A spaces")
+require(groups[1].get("spaceLabels") == ["B1", "B2"], "second Display group must contain B spaces")
+
+first_group_frame = groups[0].get("visibleFrame", {})
+second_group_frame = groups[1].get("visibleFrame", {})
+group_gap = groups[0].get("groupGap", 0)
+actual_group_gap = second_group_frame.get("x", 0) - right_edge(first_group_frame)
+require(actual_group_gap >= group_gap - 1.0, "Display group gap must be at least the reported group gap")
+require(actual_group_gap > groups[0].get("segmentGap", 0) * 2, "Display group gap must be visibly larger than inner segment gap")
+
+segments_by_label = {segment.get("label"): segment for segment in segments}
+for group in groups:
+ group_frame = group.get("visibleFrame", {})
+ labels = group.get("spaceLabels", [])
+ for label in labels:
+ require(label in segments_by_label, f"segment report must include {label}")
+ require(contains(group_frame, segments_by_label[label].get("visibleFrame", {})), f"{label} must be inside its Display group")
+
+for group in groups:
+ labels = group.get("spaceLabels", [])
+ if len(labels) < 2:
+ continue
+ previous = segments_by_label[labels[0]].get("visibleFrame", {})
+ for label in labels[1:]:
+ current = segments_by_label[label].get("visibleFrame", {})
+ actual_segment_gap = current.get("x", 0) - right_edge(previous)
+ require(
+ abs(actual_segment_gap - group.get("segmentGap", 0)) <= 1.0,
+ "inner Space segment gap must match reported segment gap"
+ )
+ previous = current
+
+focused = [segment for segment in segments if segment.get("isFocused") is True]
+require(len(focused) == 1, "exactly one Space segment must be focused")
+require(focused[0].get("label") == "B1", "hovered multi-display Space must be B1")
+require("focused" in focused[0].get("visualStates", []), "focused B1 segment must expose focused visual state")
+
+print(json.dumps({
+ "displayGroups": [
+ {
+ "label": group.get("displayLabel"),
+ "spaces": group.get("spaceLabels"),
+ "visibleFrame": group.get("visibleFrame")
+ }
+ for group in groups
+ ],
+ "actualGroupGap": actual_group_gap,
+ "focusedSpace": focused[0].get("label")
+}, indent=2, ensure_ascii=False))
+PY
+}
+
trap cleanup EXIT
acquire_lock
@@ -354,12 +601,21 @@
"$SCRIPT_DIR/package-app.sh" >&2
FOCUS_REPORT="$REPORT_DIR/round01-three-zone-linkage-focus-report.json"
+APP_HOVER_REPORT="$REPORT_DIR/round01-three-zone-linkage-app-hover-report.json"
+CLEAR_REPORT="$REPORT_DIR/round01-three-zone-linkage-clear-report.json"
SPACE_CLICK_REPORT="$REPORT_DIR/round01-three-zone-linkage-space-click-report.json"
REMOTE_REPORT="$REPORT_DIR/round01-three-zone-linkage-remote-report.json"
SCROLL_REPORT="$REPORT_DIR/round01-three-zone-linkage-scroll-report.json"
+DISPLAY_GROUP_REPORT="$REPORT_DIR/round01-three-zone-linkage-display-group-report.json"
run_report "$FOCUS_REPORT" "hover-space:1" "hover-space:1"
assert_focus_report "$FOCUS_REPORT"
+
+run_report "$APP_HOVER_REPORT" "hover-app:0" "hover-app:0"
+assert_app_hover_space_lane_report "$APP_HOVER_REPORT"
+
+run_report "$CLEAR_REPORT" "hover-space:1,hover-card:0:0" "hover-card:0:0"
+assert_space_focus_clear_report "$CLEAR_REPORT"
run_report "$SPACE_CLICK_REPORT" "click-space:1" "click-space:1" "$FIXTURE_APP_COUNT" "$FIXTURE_WINDOWS_PER_APP" hidden
assert_space_click_report "$SPACE_CLICK_REPORT"
@@ -370,6 +626,9 @@
run_report "$SCROLL_REPORT" "scroll-app-shelf:99999" "scroll-app-shelf:99999" 60 1
assert_app_shelf_scroll_report "$SCROLL_REPORT"
+run_report "$DISPLAY_GROUP_REPORT" "hover-space:4" "hover-space:4" 6 3 report-only multi-display
+assert_display_group_report "$DISPLAY_GROUP_REPORT"
+
swift "$SCRIPT_DIR/window-logic-qa.swift" --expect-no-quick-switch >&2
stop_current_aligner
release_lock
diff --git a/C3.tools/round1-waterfall-fixture-qa.sh b/C3.tools/round1-waterfall-fixture-qa.sh
index ab6f8c6..444920b 100755
--- a/C3.tools/round1-waterfall-fixture-qa.sh
+++ b/C3.tools/round1-waterfall-fixture-qa.sh
@@ -119,6 +119,18 @@
require(column.get("appGroupIndex") == expected_index, "Waterfall appGroupIndex must be sequential")
require(column.get("appName") == app_names[expected_index], "Waterfall column name must match App Shelf name")
require(column.get("windowCount") == fixture_windows_per_app, "Waterfall column window count must match fixture")
+ require(column.get("appNameAlignment") == "center", "Waterfall column app name must be center aligned")
+ column_frame = column.get("frame", {})
+ app_name_frame = column.get("appNameFrame", {})
+ count_frame = column.get("countFrame", {})
+ require(isinstance(app_name_frame, dict), "Waterfall column must expose appNameFrame")
+ require(isinstance(count_frame, dict), "Waterfall column must expose countFrame")
+ column_width = column_frame.get("width", 0)
+ app_name_center_x = app_name_frame.get("x", 0) + app_name_frame.get("width", 0) / 2
+ require(abs(app_name_center_x - column_width / 2) <= 1.0, "Waterfall column app name frame must be horizontally centered")
+ require(app_name_frame.get("width", 0) >= column_width * 0.88, "Waterfall column app name frame must span most of the title bar")
+ require(count_frame.get("x", 0) + count_frame.get("width", 0) <= column_width + 1.0, "Waterfall count frame must remain inside the title bar")
+ require(count_frame.get("x", 0) >= column_width * 0.72, "Waterfall count frame must stay in the right side of the title bar")
require(column.get("maxVerticalScrollOffset", 0) > 0, "Long Waterfall column must expose vertical scroll overflow")
require(column.get("verticalScrollOffset") == 0, "Waterfall column should start at top")
@@ -131,6 +143,10 @@
require(all(card.get("screenshotSource") == "notRequested" for card in cards), "Waterfall fixture cards must not start screenshot refresh")
require(all(card.get("screenshotNotRequestedReason") == "disabledByLaunchOption" for card in cards), "Waterfall fixture screenshot refresh must be disabled explicitly")
require(all(isinstance(card.get("thumbnailFrame"), dict) for card in cards), "Waterfall cards must expose thumbnail frames")
+ require(all(isinstance(card.get("titleBarFrame"), dict) for card in cards), "Waterfall cards must expose title bar frames")
+ require(all(card.get("titleBarBorderWidth") == 0 for card in cards), "Waterfall title area must not draw an enclosing rounded rectangle")
+ require(all(card.get("thumbnailFrame", {}).get("width", 0) >= card.get("frame", {}).get("width", 0) * 0.82 for card in cards), "Waterfall thumbnails must use most of the card width")
+ require(all(card.get("titleBarFrame", {}).get("y", 0) > card.get("thumbnailFrame", {}).get("y", 0) + card.get("thumbnailFrame", {}).get("height", 0) for card in cards), "Waterfall title bars must sit above thumbnails")
require(all(isinstance(card.get("appIconFrame"), dict) for card in cards), "Waterfall cards must expose App icon frames")
require(any(card.get("frame", {}).get("y", 0) < 0 for card in cards), "Long Waterfall column must have below-viewport cards at offset zero")
@@ -142,9 +158,9 @@
selected_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is True]
require(len(selected_cards) == 1, "Waterfall must expose exactly one selected card")
selected = selected_cards[0]
-require("selected" in selected.get("visualStates", []), "selected Waterfall card must expose selected visual state")
-require(selected.get("shineVisible") is True, "selected Waterfall card must expose shine layer")
-require(selected.get("zPosition", 0) > 0, "selected Waterfall card must be above normal cards")
+require("selectedVisualSuppressed" in selected.get("visualStates", []), "non-hovered selected Waterfall card must suppress selected visual state")
+require(selected.get("shineVisible") is False, "non-hovered selected Waterfall card must not expose shine layer")
+require(selected.get("zPosition", 0) == 0, "non-hovered selected Waterfall card must not float above normal cards")
normal_cards = [card for column in columns for card in column.get("cards", []) if card.get("isSelected") is not True]
require(normal_cards, "Waterfall fixture must include normal cards")
require(all(card.get("shineVisible") is False for card in normal_cards), "non-selected Waterfall cards must not show shine")
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\345\274\200\345\217\221\345\267\245\344\275\234-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\345\274\200\345\217\221\345\267\245\344\275\234-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
new file mode 100644
index 0000000..b492741
--- /dev/null
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\345\274\200\345\217\221\345\267\245\344\275\234-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
@@ -0,0 +1,11 @@
+# 开发工作经验与教训
+
+> 用途:记录代码、架构、构建、发布、技术实现方面的可复用教训。
+
+## 2026-06-07|已确认功能线不得当作无价值代码清理
+
+- 触发:用户指出 Quick Switch 的关闭按钮、关闭确认弹窗、关闭服务和专门 close QA 脚本是已确认并做出的功能线,被误删了。
+- 失误:AI 把未完全理解的 close 功能线当成可清理代码删除,导致交互和 QA 覆盖一起丢失。
+- 原因:清理未确认代码前没有先用 git 历史、QA 报告、用户决策记录核对功能归属。
+- 改进:删除任何已出现过 QA 报告、脚本、用户验收截图或专门服务协议的功能线前,必须先列出影响面并取得用户明确同意;恢复时必须同步恢复对应 QA 脚本。
+- 关联:`C1.source/Sources/Aligner/QuickSwitchRootView.swift`、`QuickSwitchCloseRequest.swift`、`DebugWindowCloseService.swift`、`C3.tools/round1-close-fixture-qa.sh`。
diff --git "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\351\241\271\347\233\256\347\273\217\347\220\206-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md" "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\351\241\271\347\233\256\347\273\217\347\220\206-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
index c74b436..05e2369 100644
--- "a/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\351\241\271\347\233\256\347\273\217\347\220\206-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
+++ "b/K2.\351\241\271\347\233\256\347\256\241\347\220\206/\351\241\271\347\233\256\347\273\217\347\220\206-\347\273\217\351\252\214\344\270\216\346\225\231\350\256\255.md"
@@ -10,3 +10,11 @@
- 原因:把“完成技术调研”等同于“建立可复用排查入口”,遗漏了后续研发排障场景的检索需求。
- 改进:以后对成熟竞品 / 参考源码做预研时,除专题结论外,必须同步产出源码索引;后续遇到同类问题时,先查索引,再定位源码,再记录 Aligner 是否采用。
- 关联:`01-I/04.coding/tech-pre-study/AltTab源码索引.md`、`AGENTS.md` 的“参考源码排查规则”。
+
+## 2026-06-04|不把待验证假设包装成执行方案
+
+- 触发:用户指出 Space Lane 优化中,AI 提出“真实窗口截图拼图”替代真实 Space 截图,但没有明确该方案可能造成陌生画面,也没有说明是否在等待用户决策。
+- 失误:把未经视觉和技术验证的合成截图方案当成可执行方向,弱化了“用户必须瞬间识别真实 Space”的产品目标。
+- 原因:过早从实现便利性出发收敛方案,遗漏了等待决策需显式说明的协作规则和设计中“降级缩略图不能伪装成真实截图”的约束。
+- 改进:以后涉及用户识别、原生感、真实截图等高感知 UI 方案时,先定义可验收的真实性标准;未验证前只能作为技术探索,不得直接进入实现计划。若需要用户裁决,必须明确写出正在等待什么决策。
+- 关联:`/Users/ar/.codex/AGENTS.md` 的 Waiting For User 规则、`DESIGN.md` 的缩略图规则。
--
Gitblit v1.9.3