Ariver
2026-06-12 a4af5d51e8fbb8a0e53f98aa9f9e238536aaba24
Render split view spaces with ordered app halves
16 files modified
1 files added
610 ■■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/Infrastructure/Windows/CGWindowAXWindowService.swift 30 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchRootView.swift 112 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchSessionController.swift 2 ●●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchSnapshotLoader.swift 84 ●●●●● patch | view | raw | blame | history
C1.source/Sources/AlignerCore/Models/AlignerModels.swift 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/AlignerCore/Windows/QuickSwitchViewModel.swift 64 ●●●●● patch | view | raw | blame | history
C1.source/Sources/AlignerCore/Windows/WindowEnumerationPolicy.swift 4 ●●●● patch | view | raw | blame | history
C1.source/Tests/AlignerCoreTests/AlignerCoreTests.swift 81 ●●●●● patch | view | raw | blame | history
C3.tools/round1-app-column-alignment-fixture-qa.sh 1 ●●●● patch | view | raw | blame | history
C3.tools/round1-keyboard-navigation-fixture-qa.sh 1 ●●●● patch | view | raw | blame | history
C3.tools/round1-main-ui-qa.sh 6 ●●●● patch | view | raw | blame | history
C3.tools/round1-mouse-interaction-fixture-qa.sh 2 ●●●●● patch | view | raw | blame | history
C3.tools/round1-session-snapshot-qa.sh 28 ●●●● patch | view | raw | blame | history
C3.tools/round1-space-lane-split-view-fixture-qa.sh 180 ●●●●● patch | view | raw | blame | history
C3.tools/round1-window-activation-fixture-qa.sh 3 ●●●●● patch | view | raw | blame | history
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>
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,
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,
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
    }
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),
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]
        )
    }
}
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
    }
}
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] = []
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
        )
    }
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(
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"
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 \
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
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 \
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")
C3.tools/round1-space-lane-split-view-fixture-qa.sh
New file
@@ -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"
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 \