Render split view spaces with ordered app halves
16 files modified
1 files added
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>0.0.61</string> |
| | | <string>0.0.62</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>20260612.1926</string> |
| | | <string>20260612.2049</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | | <string>26.0</string> |
| | | <key>NSHighResolutionCapable</key> |
| | |
| | | return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .spaceFilter) |
| | | } |
| | | |
| | | if round1QuickSwitchOptions.fixtureSplitView { |
| | | return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .splitView) |
| | | } |
| | | |
| | | if let fixtureAppCount = round1QuickSwitchOptions.fixtureAppCount { |
| | | return FixtureQuickSwitchSnapshotLoader( |
| | | appCount: fixtureAppCount, |
| | |
| | | return nil |
| | | } |
| | | |
| | | let rawBounds = bounds(rawWindow[kCGWindowBounds as String]) |
| | | let ownerPID = int32Value(rawWindow[kCGWindowOwnerPID as String]) |
| | | let runningApplication = ownerPID.flatMap(NSRunningApplication.init(processIdentifier:)) |
| | | let rawFingerprint = ownerPID.map { |
| | | AXWindowFingerprint( |
| | | processIdentifier: $0, |
| | | title: title(rawWindow: rawWindow, axWindow: nil), |
| | | size: bounds(rawWindow[kCGWindowBounds as String]).size |
| | | size: rawBounds.size |
| | | ) |
| | | } |
| | | let directAXWindow = axMetadata[windowID] |
| | |
| | | app: app, |
| | | activationPolicy: activationPolicy(from: runningApplication), |
| | | title: title(rawWindow: rawWindow, axWindow: axWindow), |
| | | size: bounds(rawWindow[kCGWindowBounds as String]).size, |
| | | size: rawBounds.size, |
| | | frame: rawBounds, |
| | | subrole: axWindow?.subrole ?? .standard, |
| | | isMinimized: axWindow?.isMinimized ?? false, |
| | | isFullscreen: !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs), |
| | |
| | | title: axWindow.title ?? "", |
| | | identifierSource: identifierSource, |
| | | size: axWindow.size, |
| | | frame: axWindow.frame, |
| | | subrole: axWindow.subrole, |
| | | isMinimized: axWindow.isMinimized, |
| | | isFullscreen: !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs), |
| | |
| | | let title: String? |
| | | let isMinimized: Bool |
| | | let size: CGSize |
| | | let frame: CGRect? |
| | | let subrole: AlignerWindowSubrole |
| | | let hasExplicitSubrole: Bool |
| | | let isInteractable: Bool |
| | |
| | | |
| | | for (windowIndex, window) in windows.enumerated() { |
| | | let subrole = subrole(for: window) |
| | | let size = size(for: window) |
| | | let position = position(for: window) |
| | | result.append(AXWindowMetadata( |
| | | windowID: windowID(for: window), |
| | | app: app, |
| | | activationPolicy: activationPolicy(from: application), |
| | | title: title(for: window), |
| | | isMinimized: boolAttribute(kAXMinimizedAttribute as String, for: window) ?? false, |
| | | size: size(for: window), |
| | | size: size, |
| | | frame: position.map { CGRect(origin: $0, size: size) }, |
| | | subrole: subrole.value, |
| | | hasExplicitSubrole: subrole.isExplicit, |
| | | isInteractable: boolAttribute(kAXEnabledAttribute as String, for: window) ?? true, |
| | |
| | | return size |
| | | } |
| | | |
| | | private static func position(for window: AXUIElement) -> CGPoint? { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &value) == .success, |
| | | let axValue = value, |
| | | CFGetTypeID(axValue) == AXValueGetTypeID() |
| | | else { |
| | | return nil |
| | | } |
| | | |
| | | var position = CGPoint.zero |
| | | guard AXValueGetValue(axValue as! AXValue, .cgPoint, &position) else { |
| | | return nil |
| | | } |
| | | |
| | | return position |
| | | } |
| | | |
| | | private static func subrole(for window: AXUIElement) -> (value: AlignerWindowSubrole, isExplicit: Bool) { |
| | | var value: CFTypeRef? |
| | | guard AXUIElementCopyAttributeValue(window, kAXSubroleAttribute as CFString, &value) == .success, |
| | |
| | | let labelLayer: CATextLayer |
| | | let countLayer: CATextLayer |
| | | let appLayer: CATextLayer |
| | | let splitAppLayers: [CATextLayer] |
| | | let windowBlocks: [CALayer] |
| | | let fullscreenMarkerLayer: CALayer? |
| | | } |
| | |
| | | color: space.type == .fullscreen ? .labelColor : .secondaryLabelColor, |
| | | alignment: space.type == .fullscreen ? .center : .left |
| | | ) |
| | | let splitAppLayers = splitViewAppNames(for: space).map { name in |
| | | makeTextLayer( |
| | | string: name, |
| | | fontSize: 12, |
| | | weight: .medium, |
| | | color: .labelColor, |
| | | alignment: .center |
| | | ) |
| | | } |
| | | let windowBlocks = space.type == .fullscreen |
| | | ? [] |
| | | : makeWindowBlocks(count: min(space.windowCount, 5), space: space) |
| | |
| | | container.addSublayer(appLayer) |
| | | if let fullscreenMarkerLayer { |
| | | container.addSublayer(fullscreenMarkerLayer) |
| | | } |
| | | for splitAppLayer in splitAppLayers { |
| | | container.addSublayer(splitAppLayer) |
| | | } |
| | | for block in windowBlocks { |
| | | container.addSublayer(block) |
| | |
| | | labelLayer: labelLayer, |
| | | countLayer: countLayer, |
| | | appLayer: appLayer, |
| | | splitAppLayers: splitAppLayers, |
| | | windowBlocks: windowBlocks, |
| | | fullscreenMarkerLayer: fullscreenMarkerLayer |
| | | ) |
| | |
| | | ? primaryTextColor |
| | | : secondaryTextColor |
| | | ).cgColor |
| | | for splitAppLayer in segment.splitAppLayers { |
| | | splitAppLayer.foregroundColor = primaryTextColor.cgColor |
| | | } |
| | | |
| | | for block in segment.windowBlocks { |
| | | block.backgroundColor = (isLocked |
| | |
| | | segment.countLayer.frame = CGRect(x: bounds.width - 34, y: bounds.height - 29, width: 24, height: 20) |
| | | if segment.space.type == .fullscreen { |
| | | let markerFrame = fullscreenMarkerFrame(in: bounds) |
| | | segment.appLayer.frame = CGRect( |
| | | x: 8, |
| | | y: markerFrame.midY - 8.5, |
| | | width: max(1, bounds.width - 16), |
| | | height: 17 |
| | | ) |
| | | let splitAppNames = splitViewAppNames(for: segment.space) |
| | | segment.appLayer.isHidden = splitAppNames.count >= 2 |
| | | if splitAppNames.count >= 2 { |
| | | let labelY = markerFrame.midY - 8.5 |
| | | let halfWidth = markerFrame.width / 2 |
| | | for (index, splitAppLayer) in segment.splitAppLayers.enumerated() { |
| | | let x = markerFrame.minX + CGFloat(index) * halfWidth |
| | | splitAppLayer.isHidden = false |
| | | splitAppLayer.frame = CGRect( |
| | | x: x + 4, |
| | | y: labelY, |
| | | width: max(1, halfWidth - 8), |
| | | height: 17 |
| | | ) |
| | | } |
| | | } else { |
| | | segment.appLayer.frame = CGRect( |
| | | x: 8, |
| | | y: markerFrame.midY - 8.5, |
| | | width: max(1, bounds.width - 16), |
| | | height: 17 |
| | | ) |
| | | for splitAppLayer in segment.splitAppLayers { |
| | | splitAppLayer.isHidden = true |
| | | } |
| | | } |
| | | } else { |
| | | segment.appLayer.isHidden = false |
| | | segment.appLayer.frame = CGRect(x: 10, y: 9, width: max(1, bounds.width - 20), height: 17) |
| | | for splitAppLayer in segment.splitAppLayers { |
| | | splitAppLayer.isHidden = true |
| | | } |
| | | } |
| | | if let fullscreenMarkerLayer = segment.fullscreenMarkerLayer { |
| | | layoutFullscreenMarkerLayer(fullscreenMarkerLayer, in: bounds) |
| | | layoutFullscreenMarkerLayer( |
| | | fullscreenMarkerLayer, |
| | | in: bounds, |
| | | isSplitView: splitViewAppNames(for: segment.space).count >= 2 |
| | | ) |
| | | } |
| | | |
| | | let blockGap: CGFloat = 5 |
| | |
| | | return identifiers.joined(separator: " ") |
| | | } |
| | | |
| | | private func layoutFullscreenMarkerLayer(_ layer: CALayer, in segmentBounds: CGRect) { |
| | | private func splitViewAppNames(for space: QuickSwitchSpaceViewModel) -> [String] { |
| | | guard space.type == .fullscreen else { return [] } |
| | | |
| | | let names = space.splitViewAppNames |
| | | .prefix(2) |
| | | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } |
| | | .filter { !$0.isEmpty } |
| | | |
| | | guard names.count >= 2 else { return [] } |
| | | return names |
| | | } |
| | | |
| | | private func layoutFullscreenMarkerLayer( |
| | | _ layer: CALayer, |
| | | in segmentBounds: CGRect, |
| | | isSplitView: Bool = false |
| | | ) { |
| | | let markerFrame = fullscreenMarkerFrame(in: segmentBounds) |
| | | layer.frame = markerFrame |
| | | |
| | | guard let shapeLayer = layer as? CAShapeLayer else { return } |
| | | |
| | | let bounds = shapeLayer.bounds |
| | | if isSplitView { |
| | | let insetBounds = bounds.insetBy(dx: 0.6, dy: 0.6) |
| | | let radius = min(7, max(5, insetBounds.height * 0.22)) |
| | | let path = CGMutablePath() |
| | | path.addRoundedRect( |
| | | in: insetBounds, |
| | | cornerWidth: radius, |
| | | cornerHeight: radius |
| | | ) |
| | | path.move(to: CGPoint(x: bounds.midX, y: insetBounds.minY + 1.5)) |
| | | path.addLine(to: CGPoint(x: bounds.midX, y: insetBounds.maxY - 1.5)) |
| | | shapeLayer.path = path |
| | | return |
| | | } |
| | | |
| | | let cornerLength = min(12, max(7, min(bounds.width, bounds.height) * 0.28)) |
| | | let path = CGMutablePath() |
| | | |
| | |
| | | let fullscreenMarkerCenterRatio = fullscreenMarkerFrame.map { |
| | | Double($0.midY / max(1, segment.containerLayer.bounds.height)) |
| | | } |
| | | let splitAppNames = splitViewAppNames(for: space) |
| | | let splitAppLabelFrames = segment.splitAppLayers.map { dictionary(from: $0.frame) } |
| | | return [ |
| | | "displayUUID": display.displayUUID, |
| | | "displayLabel": display.displayLabel, |
| | |
| | | "type": spaceTypeName(space.type), |
| | | "visualStates": visualStates(for: space), |
| | | "fullscreenMarkerVisible": segment.fullscreenMarkerLayer != nil, |
| | | "fullscreenMarkerKind": segment.fullscreenMarkerLayer == nil ? "none" : "cornerBracket", |
| | | "fullscreenMarkerKind": fullscreenMarkerKind( |
| | | markerLayer: segment.fullscreenMarkerLayer, |
| | | splitAppNames: splitAppNames |
| | | ), |
| | | "fullscreenMarkerFrame": fullscreenMarkerFrame.map { dictionary(from: $0) } ?? NSNull(), |
| | | "fullscreenMarkerCenterRatio": fullscreenMarkerCenterRatio ?? NSNull(), |
| | | "splitViewAppNames": splitAppNames, |
| | | "splitViewLabelFrames": splitAppLabelFrames, |
| | | "splitViewLabelColors": segment.splitAppLayers.map { |
| | | colorDictionary(from: $0.foregroundColor) |
| | | }, |
| | | "windowBlockCount": segment.windowBlocks.count, |
| | | "windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) }, |
| | | "windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) }, |
| | |
| | | } |
| | | } |
| | | |
| | | private func fullscreenMarkerKind(markerLayer: CALayer?, splitAppNames: [String]) -> String { |
| | | guard markerLayer != nil else { return "none" } |
| | | return splitAppNames.count >= 2 ? "splitViewPair" : "cornerBracket" |
| | | } |
| | | |
| | | private func visualStates(for space: QuickSwitchSpaceViewModel) -> [String] { |
| | | var states: [String] = [] |
| | | if space.isCurrent { |
| | |
| | | if space.type == .fullscreen { |
| | | states.append("fullscreen") |
| | | } |
| | | if splitViewAppNames(for: space).count >= 2 { |
| | | states.append("splitView") |
| | | } |
| | | return states |
| | | } |
| | | |
| | |
| | | let fixtureCandidateFiltering: Bool |
| | | let fixtureActivation: Bool |
| | | let fixtureSpaceFilter: Bool |
| | | let fixtureSplitView: Bool |
| | | let debugHoveredAppGroupIndex: Int? |
| | | let debugOverlayWidth: CGFloat? |
| | | let debugKeySequence: [String] |
| | |
| | | fixtureCandidateFiltering: arguments.contains("--round01-fixture-candidate-filtering"), |
| | | fixtureActivation: arguments.contains("--round01-fixture-window-activation"), |
| | | fixtureSpaceFilter: arguments.contains("--round01-fixture-space-filter"), |
| | | fixtureSplitView: arguments.contains("--round01-fixture-split-view"), |
| | | debugHoveredAppGroupIndex: intValue(for: "--round01-debug-hover-app-index", in: arguments), |
| | | debugOverlayWidth: cgFloatValue(for: "--round01-debug-overlay-width", in: arguments), |
| | | debugKeySequence: stringListValue(for: "--round01-debug-key-sequence", in: arguments), |
| | |
| | | import CoreGraphics |
| | | import Foundation |
| | | import AlignerCore |
| | | |
| | |
| | | case candidateFiltering |
| | | case activation |
| | | case spaceFilter |
| | | case splitView |
| | | } |
| | | |
| | | final class LiveQuickSwitchSnapshotLoader: QuickSwitchSnapshotLoading { |
| | |
| | | } |
| | | if mode == .spaceFilter { |
| | | return spaceFilterSnapshot() |
| | | } |
| | | if mode == .splitView { |
| | | return splitViewSnapshot() |
| | | } |
| | | |
| | | return layoutSnapshot() |
| | |
| | | currentSpaceIDs: [1, 4] |
| | | ) |
| | | } |
| | | |
| | | private func splitViewSnapshot() -> 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: .fullscreen, displayUUID: displayUUID, index: 3) |
| | | ] |
| | | ) |
| | | ] |
| | | |
| | | let normalApp = AlignerApp( |
| | | bundleIdentifier: "com.example.split.normal", |
| | | name: "普通窗口", |
| | | category: .generic, |
| | | processIdentifier: 60_001 |
| | | ) |
| | | let leftApp = AlignerApp( |
| | | bundleIdentifier: "com.example.split.left", |
| | | name: "左侧应用", |
| | | category: .finder, |
| | | processIdentifier: 60_002 |
| | | ) |
| | | let rightApp = AlignerApp( |
| | | bundleIdentifier: "com.example.split.right", |
| | | name: "右侧应用", |
| | | category: .documentNotes, |
| | | processIdentifier: 60_003 |
| | | ) |
| | | let singleFullscreenApp = AlignerApp( |
| | | bundleIdentifier: "com.example.split.single", |
| | | name: "单全屏", |
| | | category: .generic, |
| | | processIdentifier: 60_004 |
| | | ) |
| | | |
| | | let windows = [ |
| | | AlignerWindow( |
| | | id: 60_101, |
| | | app: normalApp, |
| | | title: "普通窗口", |
| | | frame: CGRect(x: 80, y: 120, width: 900, height: 620), |
| | | spaceIDs: [1] |
| | | ), |
| | | AlignerWindow( |
| | | id: 60_202, |
| | | app: rightApp, |
| | | title: "右侧 Split View", |
| | | isFullscreen: true, |
| | | frame: CGRect(x: 960, y: 0, width: 960, height: 1080), |
| | | spaceIDs: [2] |
| | | ), |
| | | AlignerWindow( |
| | | id: 60_201, |
| | | app: leftApp, |
| | | title: "左侧 Split View", |
| | | isFullscreen: true, |
| | | frame: CGRect(x: 0, y: 0, width: 960, height: 1080), |
| | | spaceIDs: [2] |
| | | ), |
| | | AlignerWindow( |
| | | id: 60_301, |
| | | app: singleFullscreenApp, |
| | | title: "单全屏", |
| | | isFullscreen: true, |
| | | frame: CGRect(x: 0, y: 0, width: 1920, height: 1080), |
| | | spaceIDs: [3] |
| | | ) |
| | | ] |
| | | |
| | | return QuickSwitchSnapshotLoad( |
| | | snapshot: QuickSwitchSnapshotBuilder.snapshot(displays: displays, windows: windows), |
| | | currentSpaceIDs: [1] |
| | | ) |
| | | } |
| | | } |
| | |
| | | import CoreGraphics |
| | | import Foundation |
| | | |
| | | public enum AlignerSpaceType: Equatable, Sendable { |
| | |
| | | public let isMinimized: Bool |
| | | public let isFullscreen: Bool |
| | | public let isGhost: Bool |
| | | public let frame: CGRect? |
| | | public let spaceIDs: [UInt64] |
| | | |
| | | public init( |
| | |
| | | isMinimized: Bool = false, |
| | | isFullscreen: Bool = false, |
| | | isGhost: Bool = false, |
| | | frame: CGRect? = nil, |
| | | spaceIDs: [UInt64] = [] |
| | | ) { |
| | | self.id = id |
| | |
| | | self.isMinimized = isMinimized |
| | | self.isFullscreen = isFullscreen |
| | | self.isGhost = isGhost |
| | | self.frame = frame |
| | | self.spaceIDs = spaceIDs |
| | | } |
| | | } |
| | |
| | | import CoreGraphics |
| | | import Foundation |
| | | |
| | | public struct QuickSwitchViewModel: Equatable, Sendable { |
| | |
| | | public let windowCount: Int |
| | | public let appCount: Int |
| | | public let appNames: [String] |
| | | public let splitViewAppNames: [String] |
| | | |
| | | public init( |
| | | id: UInt64, |
| | |
| | | isCurrent: Bool = false, |
| | | windowCount: Int, |
| | | appCount: Int, |
| | | appNames: [String] = [] |
| | | appNames: [String] = [], |
| | | splitViewAppNames: [String] = [] |
| | | ) { |
| | | self.id = id |
| | | self.displayUUID = displayUUID |
| | |
| | | self.windowCount = windowCount |
| | | self.appCount = appCount |
| | | self.appNames = appNames |
| | | self.splitViewAppNames = splitViewAppNames |
| | | } |
| | | } |
| | | |
| | |
| | | let windowCountsBySpace = countWindowsBySpace(windowItems) |
| | | let appCountsBySpace = countAppsBySpace(snapshot.appGroups) |
| | | let appNamesBySpace = appNamesBySpace(snapshot.appGroups) |
| | | let splitViewAppNamesBySpace = splitViewAppNamesBySpace(snapshot.appGroups) |
| | | let displays = displayViewModels( |
| | | from: snapshot.displays, |
| | | currentSpaceIDs: currentSpaceIDs, |
| | | windowCountsBySpace: windowCountsBySpace, |
| | | appCountsBySpace: appCountsBySpace, |
| | | appNamesBySpace: appNamesBySpace |
| | | appNamesBySpace: appNamesBySpace, |
| | | splitViewAppNamesBySpace: splitViewAppNamesBySpace |
| | | ) |
| | | let appShelf = appShelfViewModels(from: snapshot.appGroups) |
| | | |
| | |
| | | currentSpaceIDs: Set<UInt64>, |
| | | windowCountsBySpace: [UInt64: Int], |
| | | appCountsBySpace: [UInt64: Int], |
| | | appNamesBySpace: [UInt64: [String]] |
| | | appNamesBySpace: [UInt64: [String]], |
| | | splitViewAppNamesBySpace: [UInt64: [String]] |
| | | ) -> [QuickSwitchDisplayViewModel] { |
| | | let sortedDisplays = displays |
| | | .sorted { $0.uuid.localizedStandardCompare($1.uuid) == .orderedAscending } |
| | |
| | | isCurrent: currentSpaceIDs.contains(space.id), |
| | | windowCount: windowCountsBySpace[space.id] ?? 0, |
| | | appCount: appCountsBySpace[space.id] ?? 0, |
| | | appNames: appNamesBySpace[space.id] ?? [] |
| | | appNames: appNamesBySpace[space.id] ?? [], |
| | | splitViewAppNames: splitViewAppNamesBySpace[space.id] ?? [] |
| | | ) |
| | | } |
| | | |
| | |
| | | return namesBySpace |
| | | } |
| | | |
| | | private static func splitViewAppNamesBySpace(_ appGroups: [QuickSwitchAppGroup]) -> [UInt64: [String]] { |
| | | struct Candidate { |
| | | let appName: String |
| | | let windowID: UInt32 |
| | | let frame: CGRect |
| | | } |
| | | |
| | | var candidatesBySpace: [UInt64: [Candidate]] = [:] |
| | | |
| | | for group in appGroups { |
| | | for item in group.windows { |
| | | guard item.window.isFullscreen, |
| | | let primarySpaceID = item.primarySpaceID, |
| | | let frame = item.window.frame, |
| | | frame.width > 0, |
| | | frame.height > 0 |
| | | else { |
| | | continue |
| | | } |
| | | |
| | | candidatesBySpace[primarySpaceID, default: []].append( |
| | | Candidate( |
| | | appName: group.app.name, |
| | | windowID: item.window.id, |
| | | frame: frame |
| | | ) |
| | | ) |
| | | } |
| | | } |
| | | |
| | | return candidatesBySpace.compactMapValues { candidates in |
| | | guard candidates.count >= 2 else { return nil } |
| | | |
| | | return candidates |
| | | .sorted { lhs, rhs in |
| | | if lhs.frame.midX != rhs.frame.midX { |
| | | return lhs.frame.midX < rhs.frame.midX |
| | | } |
| | | if lhs.frame.midY != rhs.frame.midY { |
| | | return lhs.frame.midY < rhs.frame.midY |
| | | } |
| | | return lhs.windowID < rhs.windowID |
| | | } |
| | | .prefix(2) |
| | | .map(\.appName) |
| | | } |
| | | } |
| | | |
| | | private static func orderedUnique<T: Hashable>(_ values: [T]) -> [T] { |
| | | var seen = Set<T>() |
| | | var result: [T] = [] |
| | |
| | | public let activationPolicy: AlignerActivationPolicy |
| | | public let title: String |
| | | public let size: CGSize |
| | | public let frame: CGRect? |
| | | public let subrole: AlignerWindowSubrole |
| | | public let isMinimized: Bool |
| | | public let isFullscreen: Bool |
| | |
| | | title: String, |
| | | identifierSource: AlignerWindow.IdentifierSource = .cgWindow, |
| | | size: CGSize, |
| | | frame: CGRect? = nil, |
| | | subrole: AlignerWindowSubrole, |
| | | isMinimized: Bool = false, |
| | | isFullscreen: Bool = false, |
| | |
| | | self.activationPolicy = activationPolicy |
| | | self.title = title |
| | | self.size = size |
| | | self.frame = frame |
| | | self.subrole = subrole |
| | | self.isMinimized = isMinimized |
| | | self.isFullscreen = isFullscreen |
| | |
| | | isMinimized: record.isMinimized, |
| | | isFullscreen: record.isFullscreen, |
| | | isGhost: record.isGhost, |
| | | frame: record.frame, |
| | | spaceIDs: record.spaceIDs |
| | | ) |
| | | } |
| | |
| | | XCTAssertNil(QuickSwitchSpaceFilterPolicy.singleFullscreenSelection(inSpaceID: 12, viewModel: viewModel)) |
| | | } |
| | | |
| | | func testQuickSwitchViewModelOrdersSplitViewAppNamesByWindowFrame() { |
| | | let leftApp = AlignerApp(bundleIdentifier: "com.example.Left", name: "Left App", category: .generic) |
| | | let rightApp = AlignerApp(bundleIdentifier: "com.example.Right", name: "Right App", category: .generic) |
| | | let displayUUID = "display-a" |
| | | let snapshot = QuickSwitchSnapshotBuilder.snapshot( |
| | | displays: [ |
| | | AlignerDisplay( |
| | | uuid: displayUUID, |
| | | physical: true, |
| | | spaces: [ |
| | | AlignerSpace(id: 20, type: .fullscreen, displayUUID: displayUUID, index: 1) |
| | | ] |
| | | ) |
| | | ], |
| | | windows: [ |
| | | AlignerWindow( |
| | | id: 2, |
| | | app: rightApp, |
| | | title: "Right", |
| | | isFullscreen: true, |
| | | frame: CGRect(x: 960, y: 0, width: 960, height: 1080), |
| | | spaceIDs: [20] |
| | | ), |
| | | AlignerWindow( |
| | | id: 1, |
| | | app: leftApp, |
| | | title: "Left", |
| | | isFullscreen: true, |
| | | frame: CGRect(x: 0, y: 0, width: 960, height: 1080), |
| | | spaceIDs: [20] |
| | | ) |
| | | ] |
| | | ) |
| | | |
| | | let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot) |
| | | let space = viewModel.displays.single?.spaces.single |
| | | |
| | | XCTAssertEqual(space?.splitViewAppNames, ["Left App", "Right App"]) |
| | | XCTAssertEqual(space?.appNames, ["Left App", "Right App"]) |
| | | } |
| | | |
| | | func testQuickSwitchViewModelKeepsDuplicateAppNamesForSameAppSplitView() { |
| | | let finder = AlignerApp(bundleIdentifier: "com.apple.finder", name: "访达", category: .finder) |
| | | let displayUUID = "display-a" |
| | | let snapshot = QuickSwitchSnapshotBuilder.snapshot( |
| | | displays: [ |
| | | AlignerDisplay( |
| | | uuid: displayUUID, |
| | | physical: true, |
| | | spaces: [ |
| | | AlignerSpace(id: 21, type: .fullscreen, displayUUID: displayUUID, index: 1) |
| | | ] |
| | | ) |
| | | ], |
| | | windows: [ |
| | | AlignerWindow( |
| | | id: 12, |
| | | app: finder, |
| | | title: "右侧访达", |
| | | isFullscreen: true, |
| | | frame: CGRect(x: 900, y: 0, width: 900, height: 1000), |
| | | spaceIDs: [21] |
| | | ), |
| | | AlignerWindow( |
| | | id: 11, |
| | | app: finder, |
| | | title: "左侧访达", |
| | | isFullscreen: true, |
| | | frame: CGRect(x: 0, y: 0, width: 900, height: 1000), |
| | | spaceIDs: [21] |
| | | ) |
| | | ] |
| | | ) |
| | | |
| | | let viewModel = QuickSwitchViewModelBuilder.viewModel(from: snapshot) |
| | | let space = viewModel.displays.single?.spaces.single |
| | | |
| | | XCTAssertEqual(space?.splitViewAppNames, ["访达", "访达"]) |
| | | XCTAssertEqual(space?.appNames, ["访达"]) |
| | | } |
| | | |
| | | func testQuickSwitchFocusPolicySelectsFirstStableWindowInitially() { |
| | | let app = makeApp() |
| | | let snapshot = QuickSwitchSnapshotBuilder.snapshot( |
| | |
| | | local args=( |
| | | --round0-skip-permissions |
| | | --round01-open-quick-switch |
| | | --round01-waterfall-view-mode=vertical |
| | | --round01-fixture-app-count="$app_count" |
| | | --round01-fixture-windows-per-app="$windows_per_app" |
| | | --round01-debug-overlay-width="$overlay_width" |
| | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-waterfall-view-mode=vertical \ |
| | | --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ |
| | | --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ |
| | | --round01-disable-screenshot-refresh \ |
| | |
| | | local log="$LOG_DIR/$name.log" |
| | | |
| | | echo "==> $name" |
| | | stop_current_aligner |
| | | if "$@" >"$log" 2>&1; then |
| | | stop_current_aligner |
| | | echo "PASS $name" |
| | | else |
| | | local code="$?" |
| | | echo "FAIL $name (log: $log)" >&2 |
| | | tail -c 6000 "$log" >&2 || true |
| | | best_effort_stop_current_aligner || true |
| | | exit "$code" |
| | | fi |
| | | } |
| | |
| | | |
| | | run_step trigger-session "$SCRIPT_DIR/round1-trigger-session-qa.sh" |
| | | run_step session-snapshot "$SCRIPT_DIR/round1-session-snapshot-qa.sh" |
| | | run_step space-lane-split-view "$SCRIPT_DIR/round1-space-lane-split-view-fixture-qa.sh" |
| | | run_step candidate-filter "$SCRIPT_DIR/round1-candidate-filter-fixture-qa.sh" |
| | | run_step screenshot-session "$SCRIPT_DIR/round1-screenshot-session-qa.sh" |
| | | run_step keyboard-navigation "$SCRIPT_DIR/round1-keyboard-navigation-fixture-qa.sh" |
| | |
| | | Log directory: $LOG_DIR |
| | | Covered: trigger, overlay uniqueness, keyboard navigation, candidate filtering, |
| | | screenshots/fallbacks, minimized-window restore/activation, mouse activation, |
| | | App column alignment, and final no-residual check. |
| | | App column alignment, Split View Space Lane rendering, and final no-residual check. |
| | | EOF |
| | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-waterfall-view-mode=vertical \ |
| | | --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ |
| | | --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ |
| | | --round01-disable-screenshot-refresh \ |
| | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-waterfall-view-mode=vertical \ |
| | | --round01-fixture-app-count="$FIXTURE_APP_COUNT" \ |
| | | --round01-fixture-windows-per-app="$FIXTURE_WINDOWS_PER_APP" \ |
| | | --round01-disable-screenshot-refresh \ |
| | |
| | | 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("fullscreenMarkerKind") in {"cornerBracket", "splitViewPair"} for segment in fullscreen_segments), |
| | | "fullscreen segments must use a known fullscreen 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") |
| | |
| | | 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") |
| | | if segment.get("fullscreenMarkerKind") == "splitViewPair": |
| | | label_frames = segment.get("splitViewLabelFrames", []) |
| | | require(len(label_frames) == 2, "Split View fullscreen segments must expose two app label frames") |
| | | require( |
| | | segment.get("splitViewAppNames") and len(segment.get("splitViewAppNames")) == 2, |
| | | "Split View fullscreen segments must expose two ordered app names" |
| | | ) |
| | | require( |
| | | label_frames[0].get("x", 0) + label_frames[0].get("width", 0) / 2 |
| | | < marker_frame.get("x", 0) + marker_frame.get("width", 0) / 2 |
| | | < label_frames[1].get("x", 0) + label_frames[1].get("width", 0) / 2, |
| | | "Split View app labels must occupy left and right marker halves" |
| | | ) |
| | | for label_frame in label_frames: |
| | | app_mid_y = label_frame.get("y", 0) + label_frame.get("height", 0) / 2 |
| | | require(abs(marker_mid_y - app_mid_y) <= 2, "Split View App labels must be vertically centered in marker") |
| | | else: |
| | | require(isinstance(app_frame, dict), "fullscreen segments must expose centered App label frame") |
| | | 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") |
| New file |
| | |
| | | #!/bin/bash |
| | | # Round01 Space Lane Split View fixture QA. It verifies that a fullscreen Space |
| | | # containing two fullscreen windows is rendered as a left/right Split View tile, |
| | | # with app labels ordered by the real window frame. |
| | | |
| | | 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" |
| | | REPORT="$BUILD_REPORT_ROOT/round01-space-lane-split-view-fixture-report.json" |
| | | REPORT_WAIT="${ALIGNER_ROUND1_SPLIT_VIEW_REPORT_WAIT:-6.0}" |
| | | APP_PID="" |
| | | |
| | | fail() { |
| | | echo "Round01 Space Lane Split View 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" |
| | | } |
| | | |
| | | cleanup() { |
| | | if [ -n "${APP_PID:-}" ]; then |
| | | kill "$APP_PID" 2>/dev/null || true |
| | | wait "$APP_PID" 2>/dev/null || true |
| | | APP_PID="" |
| | | fi |
| | | stop_current_aligner |
| | | } |
| | | |
| | | wait_for_loaded_report() { |
| | | /usr/bin/python3 - "$REPORT" "$REPORT_WAIT" <<'PY' |
| | | import json |
| | | import sys |
| | | import time |
| | | |
| | | path = sys.argv[1] |
| | | timeout = float(sys.argv[2]) |
| | | deadline = time.monotonic() + timeout |
| | | last_report = None |
| | | |
| | | while time.monotonic() < deadline: |
| | | try: |
| | | with open(path, "r", encoding="utf-8") as file: |
| | | report = json.load(file) |
| | | last_report = report |
| | | if report.get("snapshotLoaded") is True: |
| | | 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"Split View fixture report did not become loaded within {timeout:.1f}s", file=sys.stderr) |
| | | sys.exit(1) |
| | | PY |
| | | } |
| | | |
| | | assert_report() { |
| | | /usr/bin/python3 - "$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) |
| | | |
| | | def mid_x(frame): |
| | | return frame.get("x", 0) + frame.get("width", 0) / 2 |
| | | |
| | | def mid_y(frame): |
| | | return frame.get("y", 0) + frame.get("height", 0) / 2 |
| | | |
| | | require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") |
| | | root = report.get("rootView", {}) |
| | | segments = root.get("spaceLaneSegments", []) |
| | | require(segments, "Space Lane segments must be reported") |
| | | |
| | | split_segments = [ |
| | | segment for segment in segments |
| | | if segment.get("fullscreenMarkerKind") == "splitViewPair" |
| | | ] |
| | | require(len(split_segments) == 1, "fixture must expose exactly one Split View Space tile") |
| | | split = split_segments[0] |
| | | require(split.get("label") == "02", "Split View fixture Space must be 02") |
| | | require(split.get("type") == "fullscreen", "Split View tile must still be a fullscreen Space") |
| | | require(split.get("windowCount") == 2, "Split View tile must count both fullscreen windows") |
| | | require(split.get("appCount") == 2, "Split View tile must count both Split View apps") |
| | | require(split.get("windowBlockCount") == 0, "Split View tile must not draw normal window blocks") |
| | | require("splitView" in split.get("visualStates", []), "Split View tile must expose splitView visual state") |
| | | require( |
| | | split.get("splitViewAppNames") == ["左侧应用", "右侧应用"], |
| | | "Split View app names must be ordered by physical window frame from left to right" |
| | | ) |
| | | |
| | | marker = split.get("fullscreenMarkerFrame", {}) |
| | | labels = split.get("splitViewLabelFrames", []) |
| | | require(isinstance(marker, dict) and marker.get("width", 0) > 0, "Split View marker frame must be exposed") |
| | | require(len(labels) == 2, "Split View tile must expose exactly two app label frames") |
| | | left, right = labels |
| | | require(mid_x(left) < marker.get("x", 0) + marker.get("width", 0) / 2, "left Split View label must sit in the left half") |
| | | require(mid_x(right) > marker.get("x", 0) + marker.get("width", 0) / 2, "right Split View label must sit in the right half") |
| | | require(mid_x(left) < mid_x(right), "Split View label frames must be ordered left to right") |
| | | require(abs(mid_y(left) - mid_y(marker)) <= 2, "left Split View label must be vertically centered") |
| | | require(abs(mid_y(right) - mid_y(marker)) <= 2, "right Split View label must be vertically centered") |
| | | |
| | | single_fullscreen = [ |
| | | segment for segment in segments |
| | | if segment.get("type") == "fullscreen" |
| | | and segment.get("windowCount") == 1 |
| | | ] |
| | | require(single_fullscreen, "fixture must include a single fullscreen regression Space") |
| | | require( |
| | | all(segment.get("fullscreenMarkerKind") == "cornerBracket" for segment in single_fullscreen), |
| | | "single fullscreen Spaces must keep the corner bracket marker" |
| | | ) |
| | | require( |
| | | all(not segment.get("splitViewAppNames") for segment in single_fullscreen), |
| | | "single fullscreen Spaces must not expose Split View labels" |
| | | ) |
| | | |
| | | print(json.dumps({ |
| | | "splitSpace": split.get("label"), |
| | | "splitNames": split.get("splitViewAppNames"), |
| | | "markerKind": split.get("fullscreenMarkerKind"), |
| | | "singleFullscreenLabels": [segment.get("label") for segment in single_fullscreen], |
| | | }, indent=2, ensure_ascii=False)) |
| | | PY |
| | | } |
| | | |
| | | trap cleanup EXIT |
| | | stop_current_aligner |
| | | "$SCRIPT_DIR/package-app.sh" >&2 |
| | | rm -f "$REPORT" |
| | | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-fixture-split-view \ |
| | | --round01-disable-screenshot-refresh \ |
| | | --round01-quick-switch-report="$REPORT" & |
| | | |
| | | APP_PID=$! |
| | | |
| | | wait_for_loaded_report |
| | | assert_report |
| | | |
| | | trap - EXIT |
| | | cleanup |
| | | echo "Round01 Space Lane Split View fixture QA passed" |
| | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-waterfall-view-mode=vertical \ |
| | | --round01-fixture-window-activation \ |
| | | --round01-disable-screenshot-refresh \ |
| | | --round01-debug-window-activation \ |
| | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-waterfall-view-mode=vertical \ |
| | | --round01-fixture-window-activation \ |
| | | --round01-disable-screenshot-refresh \ |
| | | --round01-debug-window-activation \ |
| | |
| | | "$APP/Contents/MacOS/Aligner" \ |
| | | --round0-skip-permissions \ |
| | | --round01-open-quick-switch \ |
| | | --round01-waterfall-view-mode=vertical \ |
| | | --round01-fixture-window-activation \ |
| | | --round01-disable-screenshot-refresh \ |
| | | --round01-debug-window-activation \ |