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