From a4af5d51e8fbb8a0e53f98aa9f9e238536aaba24 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 12 Jun 2026 21:08:39 +0800
Subject: [PATCH] Render split view spaces with ordered app halves
---
C3.tools/round1-session-snapshot-qa.sh | 28 ++
C3.tools/round1-keyboard-navigation-fixture-qa.sh | 1
C3.tools/round1-window-activation-fixture-qa.sh | 3
C3.tools/round1-app-column-alignment-fixture-qa.sh | 1
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | 4
C1.source/Sources/Aligner/QuickSwitchSessionController.swift | 2
C1.source/Sources/AlignerCore/Models/AlignerModels.swift | 4
C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift | 64 +++++
C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift | 4
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift | 81 +++++++
C3.tools/round1-space-lane-split-view-fixture-qa.sh | 180 ++++++++++++++++
C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift | 84 +++++++
C3.tools/round1-main-ui-qa.sh | 6
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 112 +++++++++
C1.source/Resources/Aligner-Info.plist | 4
C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift | 30 ++
C3.tools/round1-mouse-interaction-fixture-qa.sh | 2
17 files changed, 587 insertions(+), 23 deletions(-)
diff --git a/C1.source/Resources/Aligner-Info.plist b/C1.source/Resources/Aligner-Info.plist
index 6a82359..ca3405f 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.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>
diff --git a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
index 455f099..3334d05 100644
--- a/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
+++ b/C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -662,6 +662,10 @@
return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .spaceFilter)
}
+ if round1QuickSwitchOptions.fixtureSplitView {
+ return FixtureQuickSwitchSnapshotLoader(appCount: 1, mode: .splitView)
+ }
+
if let fixtureAppCount = round1QuickSwitchOptions.fixtureAppCount {
return FixtureQuickSwitchSnapshotLoader(
appCount: fixtureAppCount,
diff --git a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
index bba0912..55b5f44 100644
--- a/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
+++ b/C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift
@@ -642,13 +642,14 @@
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]
@@ -668,7 +669,8 @@
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),
@@ -748,6 +750,7 @@
title: axWindow.title ?? "",
identifierSource: identifierSource,
size: axWindow.size,
+ frame: axWindow.frame,
subrole: axWindow.subrole,
isMinimized: axWindow.isMinimized,
isFullscreen: !Set(spaceIDs).isDisjoint(with: fullscreenSpaceIDs),
@@ -990,6 +993,7 @@
let title: String?
let isMinimized: Bool
let size: CGSize
+ let frame: CGRect?
let subrole: AlignerWindowSubrole
let hasExplicitSubrole: Bool
let isInteractable: Bool
@@ -1081,13 +1085,16 @@
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,
@@ -1214,6 +1221,23 @@
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,
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index d3f89ae..4ea3319 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -226,6 +226,7 @@
let labelLayer: CATextLayer
let countLayer: CATextLayer
let appLayer: CATextLayer
+ let splitAppLayers: [CATextLayer]
let windowBlocks: [CALayer]
let fullscreenMarkerLayer: CALayer?
}
@@ -2540,6 +2541,15 @@
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)
@@ -2550,6 +2560,9 @@
container.addSublayer(appLayer)
if let fullscreenMarkerLayer {
container.addSublayer(fullscreenMarkerLayer)
+ }
+ for splitAppLayer in splitAppLayers {
+ container.addSublayer(splitAppLayer)
}
for block in windowBlocks {
container.addSublayer(block)
@@ -2562,6 +2575,7 @@
labelLayer: labelLayer,
countLayer: countLayer,
appLayer: appLayer,
+ splitAppLayers: splitAppLayers,
windowBlocks: windowBlocks,
fullscreenMarkerLayer: fullscreenMarkerLayer
)
@@ -2925,6 +2939,9 @@
? primaryTextColor
: secondaryTextColor
).cgColor
+ for splitAppLayer in segment.splitAppLayers {
+ splitAppLayer.foregroundColor = primaryTextColor.cgColor
+ }
for block in segment.windowBlocks {
block.backgroundColor = (isLocked
@@ -3074,17 +3091,45 @@
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
@@ -4671,13 +4716,44 @@
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()
@@ -5086,6 +5162,8 @@
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,
@@ -5094,9 +5172,17 @@
"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) },
@@ -5140,6 +5226,11 @@
}
}
+ 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 {
@@ -5163,6 +5254,9 @@
if space.type == .fullscreen {
states.append("fullscreen")
}
+ if splitViewAppNames(for: space).count >= 2 {
+ states.append("splitView")
+ }
return states
}
diff --git a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
index 947b96e..645a35b 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -1478,6 +1478,7 @@
let fixtureCandidateFiltering: Bool
let fixtureActivation: Bool
let fixtureSpaceFilter: Bool
+ let fixtureSplitView: Bool
let debugHoveredAppGroupIndex: Int?
let debugOverlayWidth: CGFloat?
let debugKeySequence: [String]
@@ -1506,6 +1507,7 @@
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),
diff --git a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
index ea38b1a..1cce8b6 100644
--- a/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift
@@ -1,3 +1,4 @@
+import CoreGraphics
import Foundation
import AlignerCore
@@ -15,6 +16,7 @@
case candidateFiltering
case activation
case spaceFilter
+ case splitView
}
final class LiveQuickSwitchSnapshotLoader: QuickSwitchSnapshotLoading {
@@ -86,6 +88,9 @@
}
if mode == .spaceFilter {
return spaceFilterSnapshot()
+ }
+ if mode == .splitView {
+ return splitViewSnapshot()
}
return layoutSnapshot()
@@ -494,4 +499,83 @@
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]
+ )
+ }
}
diff --git a/C1.source/Sources/AlignerCore/Models/AlignerModels.swift b/C1.source/Sources/AlignerCore/Models/AlignerModels.swift
index 8c854dc..9789783 100644
--- a/C1.source/Sources/AlignerCore/Models/AlignerModels.swift
+++ b/C1.source/Sources/AlignerCore/Models/AlignerModels.swift
@@ -1,3 +1,4 @@
+import CoreGraphics
import Foundation
public enum AlignerSpaceType: Equatable, Sendable {
@@ -81,6 +82,7 @@
public let isMinimized: Bool
public let isFullscreen: Bool
public let isGhost: Bool
+ public let frame: CGRect?
public let spaceIDs: [UInt64]
public init(
@@ -91,6 +93,7 @@
isMinimized: Bool = false,
isFullscreen: Bool = false,
isGhost: Bool = false,
+ frame: CGRect? = nil,
spaceIDs: [UInt64] = []
) {
self.id = id
@@ -100,6 +103,7 @@
self.isMinimized = isMinimized
self.isFullscreen = isFullscreen
self.isGhost = isGhost
+ self.frame = frame
self.spaceIDs = spaceIDs
}
}
diff --git a/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift b/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
index 1bec9df..18bc70a 100644
--- a/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
+++ b/C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift
@@ -1,3 +1,4 @@
+import CoreGraphics
import Foundation
public struct QuickSwitchViewModel: Equatable, Sendable {
@@ -62,6 +63,7 @@
public let windowCount: Int
public let appCount: Int
public let appNames: [String]
+ public let splitViewAppNames: [String]
public init(
id: UInt64,
@@ -72,7 +74,8 @@
isCurrent: Bool = false,
windowCount: Int,
appCount: Int,
- appNames: [String] = []
+ appNames: [String] = [],
+ splitViewAppNames: [String] = []
) {
self.id = id
self.displayUUID = displayUUID
@@ -83,6 +86,7 @@
self.windowCount = windowCount
self.appCount = appCount
self.appNames = appNames
+ self.splitViewAppNames = splitViewAppNames
}
}
@@ -166,12 +170,14 @@
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)
@@ -210,7 +216,8 @@
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 }
@@ -232,7 +239,8 @@
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] ?? []
)
}
@@ -309,6 +317,54 @@
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] = []
diff --git a/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift b/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
index 586fc83..16c3e0c 100644
--- a/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
+++ b/C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift
@@ -22,6 +22,7 @@
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
@@ -40,6 +41,7 @@
title: String,
identifierSource: AlignerWindow.IdentifierSource = .cgWindow,
size: CGSize,
+ frame: CGRect? = nil,
subrole: AlignerWindowSubrole,
isMinimized: Bool = false,
isFullscreen: Bool = false,
@@ -57,6 +59,7 @@
self.activationPolicy = activationPolicy
self.title = title
self.size = size
+ self.frame = frame
self.subrole = subrole
self.isMinimized = isMinimized
self.isFullscreen = isFullscreen
@@ -106,6 +109,7 @@
isMinimized: record.isMinimized,
isFullscreen: record.isFullscreen,
isGhost: record.isGhost,
+ frame: record.frame,
spaceIDs: record.spaceIDs
)
}
diff --git a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
index 3c1be22..b83efb7 100644
--- a/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
+++ b/C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift
@@ -1441,6 +1441,87 @@
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(
diff --git a/C3.tools/round1-app-column-alignment-fixture-qa.sh b/C3.tools/round1-app-column-alignment-fixture-qa.sh
index 9dd6692..963fc2b 100755
--- a/C3.tools/round1-app-column-alignment-fixture-qa.sh
+++ b/C3.tools/round1-app-column-alignment-fixture-qa.sh
@@ -254,6 +254,7 @@
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"
diff --git a/C3.tools/round1-keyboard-navigation-fixture-qa.sh b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
index 8dc7de2..89eba81 100755
--- a/C3.tools/round1-keyboard-navigation-fixture-qa.sh
+++ b/C3.tools/round1-keyboard-navigation-fixture-qa.sh
@@ -183,6 +183,7 @@
"$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 \
diff --git a/C3.tools/round1-main-ui-qa.sh b/C3.tools/round1-main-ui-qa.sh
index 2652fab..aadf711 100755
--- a/C3.tools/round1-main-ui-qa.sh
+++ b/C3.tools/round1-main-ui-qa.sh
@@ -78,12 +78,15 @@
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
}
@@ -102,6 +105,7 @@
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"
@@ -120,5 +124,5 @@
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
diff --git a/C3.tools/round1-mouse-interaction-fixture-qa.sh b/C3.tools/round1-mouse-interaction-fixture-qa.sh
index c8c885f..fe212d7 100755
--- a/C3.tools/round1-mouse-interaction-fixture-qa.sh
+++ b/C3.tools/round1-mouse-interaction-fixture-qa.sh
@@ -327,6 +327,7 @@
"$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 \
@@ -353,6 +354,7 @@
"$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 \
diff --git a/C3.tools/round1-session-snapshot-qa.sh b/C3.tools/round1-session-snapshot-qa.sh
index 981dd57..1dac74a 100755
--- a/C3.tools/round1-session-snapshot-qa.sh
+++ b/C3.tools/round1-session-snapshot-qa.sh
@@ -143,7 +143,10 @@
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")
@@ -152,10 +155,27 @@
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")
diff --git a/C3.tools/round1-space-lane-split-view-fixture-qa.sh b/C3.tools/round1-space-lane-split-view-fixture-qa.sh
new file mode 100755
index 0000000..335fcd6
--- /dev/null
+++ b/C3.tools/round1-space-lane-split-view-fixture-qa.sh
@@ -0,0 +1,180 @@
+#!/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"
diff --git a/C3.tools/round1-window-activation-fixture-qa.sh b/C3.tools/round1-window-activation-fixture-qa.sh
index 2be2897..4a8ddb8 100755
--- a/C3.tools/round1-window-activation-fixture-qa.sh
+++ b/C3.tools/round1-window-activation-fixture-qa.sh
@@ -145,6 +145,7 @@
"$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 \
@@ -170,6 +171,7 @@
"$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 \
@@ -187,6 +189,7 @@
"$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 \
--
Gitblit v1.9.3