Ariver
2026-06-14 4d4294f8c375a06ad87e12a2c73a98a19eb7ed7e
Add Quick Switch view mode toggle
5 files modified
430 ■■■■ changed files
C1.source/Resources/Aligner-Info.plist 4 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift 21 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchRootView.swift 274 ●●●● patch | view | raw | blame | history
C1.source/Sources/Aligner/QuickSwitchSessionController.swift 6 ●●●● patch | view | raw | blame | history
C3.tools/round1-view-mode-entry-qa.sh 125 ●●●●● 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.67</string>
    <string>0.0.68</string>
    <key>CFBundleVersion</key>
    <string>20260614.1559</string>
    <string>20260614.1827</string>
    <key>LSMinimumSystemVersion</key>
    <string>26.0</string>
    <key>NSHighResolutionCapable</key>
C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -494,8 +494,8 @@
            controller.onSnapshotUpdated = { [weak self] in
                self?.writeQuickSwitchReportIfNeeded()
            }
            controller.onOpenSettings = { [weak self] in
                self?.showSettingsFromQuickSwitch()
            controller.onToggleWaterfallViewMode = { [weak self] in
                self?.toggleQuickSwitchWaterfallViewModeFromQuickSwitch()
            }
            quickSwitchSessionController = controller
        }
@@ -573,8 +573,8 @@
            controller.onSnapshotUpdated = { [weak self] in
                self?.writeQuickSwitchReportIfNeeded()
            }
            controller.onOpenSettings = { [weak self] in
                self?.showSettingsFromQuickSwitch()
            controller.onToggleWaterfallViewMode = { [weak self] in
                self?.toggleQuickSwitchWaterfallViewModeFromQuickSwitch()
            }
            quickSwitchSessionController = controller
        }
@@ -749,6 +749,19 @@
        ])
    }
    private func toggleQuickSwitchWaterfallViewModeFromQuickSwitch() {
        let currentMode = quickSwitchWaterfallViewMode()
        let nextMode: QuickSwitchWaterfallViewMode = currentMode == .horizontalMasonry
            ? .verticalColumns
            : .horizontalMasonry
        DevelopmentDiagnostics.log("app.quickSwitch.waterfallViewModeToggle", [
            "currentMode": currentMode.rawValue,
            "nextMode": nextMode.rawValue,
            "quickSwitchVisible": quickSwitchSessionController?.isVisible ?? false
        ])
        setQuickSwitchWaterfallViewMode(nextMode)
    }
    private func appVersionBuild() -> (version: String, build: String) {
        let info = Bundle.main.infoDictionary ?? [:]
        let version = info["CFBundleShortVersionString"] as? String ?? "Unknown"
C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -24,9 +24,8 @@
    private lazy var closeConfirmationLayers = makeCloseConfirmationLayers()
    private let closeFeedbackLayer = CALayer()
    private let closeFeedbackTextLayer = CATextLayer()
    private let settingsButtonLayer = CALayer()
    private let settingsButtonIconLayer = CALayer()
    private let settingsButtonFallbackTextLayer = CATextLayer()
    private let viewModeToggleButtonLayer = CALayer()
    private let viewModeToggleIconLayer = CALayer()
    private let appIconProvider: any AppIconProviderProtocol = WorkspaceAppIconProvider()
    private let skeletonThumbnailProvider = NativeSkeletonThumbnailProvider(size: NSSize(width: 160, height: 96))
    private var screenshotSourcesByWindowID: [UInt32: ScreenshotResolutionSource] = [:]
@@ -52,7 +51,7 @@
    private var closeConfirmationRequired = true
    private var closeFeedback: CloseFeedback?
    private var closeFeedbackHideTask: Task<Void, Never>?
    private var isSettingsButtonHovered = false
    private var isViewModeToggleButtonHovered = false
    private var hoverTarget: HoverTarget = .none
    private var hoverTargetSource: HoverTargetSource = .event
    private var hoveredSpaceLaneID: UInt64?
@@ -95,7 +94,7 @@
    var onCommitSelection: ((QuickSwitchSelection, QuickSwitchCommitSource) -> Void)?
    var onSpaceLaneClick: ((UInt64, Int) -> Void)?
    var onBackgroundClick: (() -> Bool)?
    var onOpenSettings: (() -> Void)?
    var onToggleWaterfallViewMode: (() -> Void)?
    var onRequestClose: ((QuickSwitchCloseRequest) -> Void)?
    var onCloseConfirmationDisabled: (() -> Void)?
@@ -485,12 +484,12 @@
    override func mouseMoved(with event: NSEvent) {
        guard debugHoveredAppGroupIndex == nil else { return }
        let settingsHovered = settingsButtonContains(windowLocation: event.locationInWindow)
        if isSettingsButtonHovered != settingsHovered {
            isSettingsButtonHovered = settingsHovered
        let toggleHovered = viewModeToggleButtonContains(windowLocation: event.locationInWindow)
        if isViewModeToggleButtonHovered != toggleHovered {
            isViewModeToggleButtonHovered = toggleHovered
            needsLayout = true
        }
        if settingsHovered {
        if toggleHovered {
            hoveredCloseTarget = nil
            setHoverTarget(.none)
            return
@@ -506,7 +505,7 @@
    override func mouseExited(with event: NSEvent) {
        guard debugHoveredAppGroupIndex == nil else { return }
        isSettingsButtonHovered = false
        isViewModeToggleButtonHovered = false
        hoveredCloseTarget = nil
        setHoverTarget(.none)
    }
@@ -525,8 +524,8 @@
            return
        }
        if settingsButtonContains(windowLocation: event.locationInWindow) {
            clickSettingsButton(source: "mouse")
        if viewModeToggleButtonContains(windowLocation: event.locationInWindow) {
            clickViewModeToggleButton(source: "mouse")
            return
        }
@@ -1081,9 +1080,14 @@
            "activeSpaceFocusID": activeSpaceFocusID ?? NSNull(),
            "spaceFilterLockedSpaceID": currentViewModel?.lockedSpaceID ?? NSNull(),
            "spaceFilterActive": currentViewModel?.lockedSpaceID != nil,
            "settingsButtonVisible": !settingsButtonLayer.isHidden,
            "settingsButtonHovered": isSettingsButtonHovered,
            "settingsButtonFrame": dictionary(from: settingsButtonLayer.frame),
            "viewModeToggleButtonVisible": !viewModeToggleButtonLayer.isHidden,
            "viewModeToggleButtonHovered": isViewModeToggleButtonHovered,
            "viewModeToggleButtonFrame": dictionary(from: viewModeToggleButtonLayer.frame),
            "viewModeToggleCurrentMode": waterfallViewMode.rawValue,
            "viewModeToggleTargetMode": nextWaterfallViewMode.rawValue,
            "viewModeToggleIconKind": viewModeToggleIconKind,
            "viewModeToggleAccessibilityLabel": viewModeToggleAccessibilityLabel,
            "settingsButtonVisible": false,
            "spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(),
            "appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(),
            "appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels,
@@ -1326,9 +1330,9 @@
        case "click-background":
            recordMouseCommand("click-background")
            _ = onBackgroundClick?()
        case "click-settings":
            recordMouseCommand("click-settings")
            clickSettingsButton(source: "debug")
        case "click-view-toggle", "click-settings":
            recordMouseCommand("click-view-toggle")
            clickViewModeToggleButton(source: "debug")
        case "hover-card" where parts.count == 3:
            guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else {
                recordMouseCommand("invalid:\(command)")
@@ -2281,25 +2285,15 @@
        closeFeedbackLayer.addSublayer(closeFeedbackTextLayer)
        glassLayer.addSublayer(closeFeedbackLayer)
        settingsButtonLayer.cornerRadius = 10
        settingsButtonLayer.masksToBounds = false
        settingsButtonLayer.borderWidth = 1
        settingsButtonLayer.shadowColor = NSColor.black.cgColor
        settingsButtonLayer.shadowOffset = CGSize(width: 0, height: -2)
        settingsButtonLayer.zPosition = 170
        settingsButtonIconLayer.contentsGravity = .resizeAspect
        settingsButtonIconLayer.contentsScale = backingScaleFactor
        settingsButtonIconLayer.contents = settingsIconImage()
        settingsButtonFallbackTextLayer.contentsScale = backingScaleFactor
        settingsButtonFallbackTextLayer.string = "⚙"
        settingsButtonFallbackTextLayer.font = NSFont.systemFont(ofSize: 18, weight: .regular)
        settingsButtonFallbackTextLayer.fontSize = 18
        settingsButtonFallbackTextLayer.alignmentMode = .center
        settingsButtonFallbackTextLayer.foregroundColor = NSColor.secondaryLabelColor.cgColor
        settingsButtonFallbackTextLayer.isHidden = settingsButtonIconLayer.contents != nil
        settingsButtonLayer.addSublayer(settingsButtonIconLayer)
        settingsButtonLayer.addSublayer(settingsButtonFallbackTextLayer)
        glassLayer.addSublayer(settingsButtonLayer)
        viewModeToggleButtonLayer.cornerRadius = 10
        viewModeToggleButtonLayer.masksToBounds = false
        viewModeToggleButtonLayer.borderWidth = 1
        viewModeToggleButtonLayer.shadowColor = NSColor.black.cgColor
        viewModeToggleButtonLayer.shadowOffset = CGSize(width: 0, height: -2)
        viewModeToggleButtonLayer.zPosition = 170
        viewModeToggleIconLayer.contentsScale = backingScaleFactor
        viewModeToggleButtonLayer.addSublayer(viewModeToggleIconLayer)
        glassLayer.addSublayer(viewModeToggleButtonLayer)
    }
    private func rebuildAppShelfItems() {
@@ -2650,12 +2644,12 @@
        layoutWaterfallColumns()
        layoutCloseConfirmation()
        layoutCloseFeedback()
        layoutSettingsButton()
        layoutViewModeToggleButton()
        CATransaction.commit()
    }
    private func layoutSettingsButton() {
    private func layoutViewModeToggleButton() {
        let size: CGFloat = 34
        let topMargin = topSafeAreaInset + RootLayoutMetrics.topContentMargin
        let x = max(
@@ -2666,24 +2660,139 @@
            RootLayoutMetrics.bottomMargin,
            glassLayer.bounds.height - topMargin - size
        )
        settingsButtonLayer.frame = CGRect(x: x, y: y, width: size, height: size)
        settingsButtonLayer.backgroundColor = NSColor.windowBackgroundColor
            .withAlphaComponent(isSettingsButtonHovered ? 0.78 : 0.54)
        viewModeToggleButtonLayer.frame = CGRect(x: x, y: y, width: size, height: size)
        viewModeToggleButtonLayer.backgroundColor = NSColor.windowBackgroundColor
            .withAlphaComponent(isViewModeToggleButtonHovered ? 0.78 : 0.54)
            .cgColor
        settingsButtonLayer.borderColor = NSColor.separatorColor
            .withAlphaComponent(isSettingsButtonHovered ? 0.42 : 0.26)
        viewModeToggleButtonLayer.borderColor = NSColor.separatorColor
            .withAlphaComponent(isViewModeToggleButtonHovered ? 0.42 : 0.26)
            .cgColor
        settingsButtonLayer.shadowOpacity = isSettingsButtonHovered ? 0.14 : 0.08
        settingsButtonLayer.shadowRadius = isSettingsButtonHovered ? 9 : 6
        settingsButtonIconLayer.contentsScale = backingScaleFactor
        settingsButtonFallbackTextLayer.contentsScale = backingScaleFactor
        let iconInset: CGFloat = 8
        settingsButtonIconLayer.frame = settingsButtonLayer.bounds.insetBy(dx: iconInset, dy: iconInset)
        settingsButtonFallbackTextLayer.frame = settingsButtonLayer.bounds.insetBy(dx: 0, dy: 6)
        settingsButtonFallbackTextLayer.foregroundColor = (isSettingsButtonHovered
            ? NSColor.labelColor
        viewModeToggleButtonLayer.shadowOpacity = isViewModeToggleButtonHovered ? 0.14 : 0.08
        viewModeToggleButtonLayer.shadowRadius = isViewModeToggleButtonHovered ? 9 : 6
        viewModeToggleIconLayer.contentsScale = backingScaleFactor
        let iconInset: CGFloat = 7
        viewModeToggleIconLayer.frame = viewModeToggleButtonLayer.bounds.insetBy(dx: iconInset, dy: iconInset)
        layoutViewModeToggleIcon()
    }
    private func layoutViewModeToggleIcon() {
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        viewModeToggleIconLayer.sublayers?.forEach { $0.removeFromSuperlayer() }
        let bounds = viewModeToggleIconLayer.bounds
        guard bounds.width > 0, bounds.height > 0 else {
            CATransaction.commit()
            return
        }
        let activeStroke = (isViewModeToggleButtonHovered
            ? NSColor.controlAccentColor
            : NSColor.secondaryLabelColor
        ).cgColor
        ).withAlphaComponent(isViewModeToggleButtonHovered ? 0.88 : 0.82)
        let quietStroke = (isViewModeToggleButtonHovered
            ? NSColor.controlAccentColor
            : NSColor.secondaryLabelColor
        ).withAlphaComponent(isViewModeToggleButtonHovered ? 0.34 : 0.24)
        let subtleFill = (isViewModeToggleButtonHovered
            ? NSColor.controlAccentColor
            : NSColor.labelColor
        ).withAlphaComponent(isViewModeToggleButtonHovered ? 0.06 : 0.025)
        addViewModeToggleRoundedRect(
            bounds.insetBy(dx: 0.65, dy: 0.65),
            cornerRadius: 4.8,
            strokeColor: quietStroke,
            fillColor: NSColor.clear,
            lineWidth: 1.05
        )
        switch nextWaterfallViewMode {
        case .verticalColumns:
            addVerticalColumnsToggleGlyph(
                in: bounds.insetBy(dx: 4.15, dy: 3.25),
                strokeColor: activeStroke,
                fillColor: subtleFill
            )
        case .horizontalMasonry:
            addHorizontalMasonryToggleGlyph(
                in: bounds.insetBy(dx: 3.95, dy: 3.95),
                strokeColor: activeStroke,
                fillColor: subtleFill
            )
        }
        CATransaction.commit()
    }
    private func addVerticalColumnsToggleGlyph(
        in rect: CGRect,
        strokeColor: NSColor,
        fillColor: NSColor
    ) {
        let columnWidth: CGFloat = 3.35
        let gap = max(1.7, (rect.width - columnWidth * 3) / 2)
        for index in 0..<3 {
            let x = rect.minX + CGFloat(index) * (columnWidth + gap)
            let columnRect = CGRect(x: x, y: rect.minY, width: columnWidth, height: rect.height)
            addViewModeToggleRoundedRect(
                columnRect,
                cornerRadius: 1.6,
                strokeColor: strokeColor,
                fillColor: fillColor,
                lineWidth: 1.22
            )
        }
    }
    private func addHorizontalMasonryToggleGlyph(
        in rect: CGRect,
        strokeColor: NSColor,
        fillColor: NSColor
    ) {
        let cellSize: CGFloat = 3.65
        let gapX = max(1.65, (rect.width - cellSize * 3) / 2)
        let gapY = max(1.65, (rect.height - cellSize * 3) / 2)
        for row in 0..<3 {
            for column in 0..<3 {
                let cellRect = CGRect(
                    x: rect.minX + CGFloat(column) * (cellSize + gapX),
                    y: rect.minY + CGFloat(row) * (cellSize + gapY),
                    width: cellSize,
                    height: cellSize
                )
                addViewModeToggleRoundedRect(
                    cellRect,
                    cornerRadius: 1.15,
                    strokeColor: strokeColor,
                    fillColor: fillColor,
                    lineWidth: 1.18
                )
            }
        }
    }
    private func addViewModeToggleRoundedRect(
        _ rect: CGRect,
        cornerRadius: CGFloat,
        strokeColor: NSColor,
        fillColor: NSColor,
        lineWidth: CGFloat
    ) {
        let layer = CAShapeLayer()
        layer.contentsScale = backingScaleFactor
        layer.frame = viewModeToggleIconLayer.bounds
        layer.path = CGPath(
            roundedRect: rect,
            cornerWidth: cornerRadius,
            cornerHeight: cornerRadius,
            transform: nil
        )
        layer.fillColor = fillColor.cgColor
        layer.strokeColor = strokeColor.cgColor
        layer.lineWidth = lineWidth
        layer.lineJoin = .round
        viewModeToggleIconLayer.addSublayer(layer)
    }
    private var topSafeAreaInset: CGFloat {
@@ -3035,24 +3144,26 @@
        spaceLaneSegments.first { $0.space.id == spaceID }
    }
    private func settingsButtonContains(windowLocation: CGPoint) -> Bool {
    private func viewModeToggleButtonContains(windowLocation: CGPoint) -> Bool {
        let locationInView = convert(windowLocation, from: nil)
        let locationInGlass = CGPoint(
            x: locationInView.x - glassLayer.frame.minX,
            y: locationInView.y - glassLayer.frame.minY
        )
        return settingsButtonLayer.frame.contains(locationInGlass)
        return viewModeToggleButtonLayer.frame.contains(locationInGlass)
    }
    private func clickSettingsButton(source: String) {
        DevelopmentDiagnostics.log("quickSwitch.view.clickSettings", [
            "source": source
    private func clickViewModeToggleButton(source: String) {
        DevelopmentDiagnostics.log("quickSwitch.view.clickWaterfallViewModeToggle", [
            "source": source,
            "currentMode": waterfallViewMode.rawValue,
            "targetMode": nextWaterfallViewMode.rawValue
        ])
        lastMouseCommand = "click-settings"
        if !mouseCommandsApplied.contains("click-settings") {
            mouseCommandsApplied.append("click-settings")
        lastMouseCommand = "click-view-toggle"
        if !mouseCommandsApplied.contains("click-view-toggle") {
            mouseCommandsApplied.append("click-view-toggle")
        }
        onOpenSettings?()
        onToggleWaterfallViewMode?()
    }
    private func appShelfItem(at windowLocation: CGPoint) -> AppShelfItemLayers? {
@@ -4448,6 +4559,28 @@
        waterfallViewMode == .horizontalMasonry
    }
    private var nextWaterfallViewMode: QuickSwitchWaterfallViewMode {
        isHorizontalMasonryMode ? .verticalColumns : .horizontalMasonry
    }
    private var viewModeToggleIconKind: String {
        switch nextWaterfallViewMode {
        case .verticalColumns:
            return "verticalColumnsTarget"
        case .horizontalMasonry:
            return "horizontalMasonryTarget"
        }
    }
    private var viewModeToggleAccessibilityLabel: String {
        switch nextWaterfallViewMode {
        case .verticalColumns:
            return "切换到纵栏瀑布"
        case .horizontalMasonry:
            return "切换到横栏瀑布"
        }
    }
    private var waterfallMaxScrollOffset: CGFloat {
        guard !isHorizontalMasonryMode else { return 0 }
        return max(0, waterfallContentWidth - waterfallLayer.bounds.width)
@@ -4565,21 +4698,6 @@
        shapeLayer.lineCap = .round
        shapeLayer.lineJoin = .round
        return shapeLayer
    }
    private func settingsIconImage() -> CGImage? {
        let names = [
            NSImage.Name("NSPreferencesGeneral"),
            NSImage.Name("NSAdvanced")
        ]
        for name in names {
            guard let image = NSImage(named: name) else { continue }
            var rect = CGRect(origin: .zero, size: image.size)
            if let cgImage = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
                return cgImage
            }
        }
        return nil
    }
    private func makeTextLayer(
C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -86,7 +86,7 @@
    private var lifecycleResidualOverlayWindows = 0
    private var lifecycleResidualVisibleOverlayWindows = 0
    var onSnapshotUpdated: (() -> Void)?
    var onOpenSettings: (() -> Void)?
    var onToggleWaterfallViewMode: (() -> Void)?
    private struct PendingCloseVerification {
        let request: QuickSwitchCloseRequest
@@ -213,8 +213,8 @@
        view.onBackgroundClick = { [weak self] in
            self?.handleBackgroundClick() ?? false
        }
        view.onOpenSettings = { [weak self] in
            self?.onOpenSettings?()
        view.onToggleWaterfallViewMode = { [weak self] in
            self?.onToggleWaterfallViewMode?()
        }
        view.setSuppressEventInput(!debugMouseSequence.isEmpty || !debugKeySequence.isEmpty)
        view.onRequestClose = { [weak self] request in
C3.tools/round1-view-mode-entry-qa.sh
@@ -1,7 +1,7 @@
#!/bin/bash
# Round01.1 view-mode entry QA. It verifies that the installed-app path exposes
# a discoverable status-menu entry for switching Quick Switch Waterfall view
# modes and reports visible About version/build metadata.
# menu entries plus the in-panel Quick Switch view-mode toggle for switching
# Waterfall view modes, and reports visible About version/build metadata.
set -euo pipefail
@@ -12,7 +12,8 @@
APP="$BUILD_CURRENT_APP"
REPORT_VERTICAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-vertical-report.json"
REPORT_HORIZONTAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-horizontal-report.json"
REPORT_SETTINGS="$BUILD_REPORT_ROOT/round01-view-mode-entry-settings-report.json"
REPORT_TOGGLE_TO_HORIZONTAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-toggle-to-horizontal-report.json"
REPORT_TOGGLE_TO_VERTICAL="$BUILD_REPORT_ROOT/round01-view-mode-entry-toggle-to-vertical-report.json"
DOMAIN="com.ar.Aligner"
WATERFALL_MODE_KEY="com.ar.Aligner.preferences.quickSwitch.waterfallViewMode"
REPORT_WAIT="${ALIGNER_ROUND1_VIEW_MODE_ENTRY_REPORT_WAIT:-6.0}"
@@ -135,6 +136,9 @@
main_menu = report.get("mainMenu", {})
settings_window = report.get("settingsWindow", {})
root = report.get("rootView", {})
expected_target_mode = "verticalColumns" if expected_mode == "horizontalMasonry" else "horizontalMasonry"
expected_icon_kind = "verticalColumnsTarget" if expected_target_mode == "verticalColumns" else "horizontalMasonryTarget"
expected_accessibility_label = "切换到纵栏瀑布" if expected_target_mode == "verticalColumns" else "切换到横栏瀑布"
require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true")
require(report.get("quickSwitchVisible") is True, "Quick Switch must be visible during entry QA")
@@ -154,10 +158,15 @@
require(main_menu.get("settingsKeyEquivalent") == ",", "Settings must use Cmd+, key equivalent")
require(main_menu.get("hasQuickSwitchViewSubmenuInAppMenu") is True, "app menu must expose Quick Switch View submenu")
require(main_menu.get("hasQuickSwitchViewSubmenuInViewMenu") is True, "View menu must expose Quick Switch View submenu")
require(root.get("settingsButtonVisible") is True, "Quick Switch must expose a top-right Settings button")
settings_frame = root.get("settingsButtonFrame", {})
require(settings_frame.get("width", 0) >= 30 and settings_frame.get("height", 0) >= 30, "Settings button must have tappable size")
require(settings_frame.get("x", 0) + settings_frame.get("width", 0) > root.get("bounds", {}).get("width", 0) * 0.90, "Settings button must sit near the top-right edge")
require(root.get("settingsButtonVisible") is False, "Quick Switch must not expose the old top-right Settings button")
require(root.get("viewModeToggleButtonVisible") is True, "Quick Switch must expose a top-right view-mode toggle button")
toggle_frame = root.get("viewModeToggleButtonFrame", {})
require(toggle_frame.get("width", 0) >= 30 and toggle_frame.get("height", 0) >= 30, "view-mode toggle button must have tappable size")
require(toggle_frame.get("x", 0) + toggle_frame.get("width", 0) > root.get("bounds", {}).get("width", 0) * 0.90, "view-mode toggle button must sit near the top-right edge")
require(root.get("viewModeToggleCurrentMode") == expected_mode, "view-mode toggle must report the current mode")
require(root.get("viewModeToggleTargetMode") == expected_target_mode, "view-mode toggle must report the next target mode")
require(root.get("viewModeToggleIconKind") == expected_icon_kind, "view-mode toggle icon kind must represent the target mode")
require(root.get("viewModeToggleAccessibilityLabel") == expected_accessibility_label, "view-mode toggle label must name the target mode")
require(settings_window.get("visible") is False, "Settings window should not be open in passive view-mode entry cases")
require(report.get("aboutVersion") == app_version, "About version must match app Info.plist")
require(report.get("aboutBuild") == app_build, "About build must match app Info.plist")
@@ -170,21 +179,25 @@
    "aboutBuild": report.get("aboutBuild"),
    "statusMenu": status_menu,
    "mainMenu": main_menu,
    "settingsButtonFrame": settings_frame
    "viewModeToggleButtonFrame": toggle_frame,
    "viewModeToggleTargetMode": root.get("viewModeToggleTargetMode"),
    "viewModeToggleIconKind": root.get("viewModeToggleIconKind")
}, indent=2, ensure_ascii=False))
PY
}
wait_for_settings_report() {
wait_for_toggle_report() {
  local report="$1"
  local expected_mode="$2"
  /usr/bin/python3 - "$report" "$REPORT_WAIT" <<'PY'
  /usr/bin/python3 - "$report" "$expected_mode" "$REPORT_WAIT" <<'PY'
import json
import sys
import time
path = sys.argv[1]
timeout = float(sys.argv[2])
expected_mode = sys.argv[2]
timeout = float(sys.argv[3])
deadline = time.monotonic() + timeout
last_report = None
@@ -193,7 +206,14 @@
        with open(path, "r", encoding="utf-8") as file:
            report = json.load(file)
        last_report = report
        if report.get("settingsWindow", {}).get("visible") is True:
        root = report.get("rootView", {})
        if (
            report.get("snapshotLoaded") is True
            and report.get("quickSwitchVisible") is True
            and report.get("resolvedWaterfallViewMode") == expected_mode
            and root.get("waterfallViewMode") == expected_mode
            and root.get("lastMouseCommand") == "click-view-toggle"
        ):
            sys.exit(0)
    except FileNotFoundError:
        pass
@@ -203,23 +223,31 @@
if last_report is not None:
    print(json.dumps(last_report, indent=2, ensure_ascii=False), file=sys.stderr)
print(f"settings report did not become visible within {timeout:.1f}s", file=sys.stderr)
print(f"view-mode toggle report did not reach {expected_mode} within {timeout:.1f}s", file=sys.stderr)
sys.exit(1)
PY
}
assert_settings_report() {
assert_toggle_report() {
  local report="$1"
  local app_version="$2"
  local app_build="$3"
  local initial_mode="$2"
  local expected_mode="$3"
  local expected_vertical_state="$4"
  local expected_horizontal_state="$5"
  local app_version="$6"
  local app_build="$7"
  /usr/bin/python3 - "$report" "$app_version" "$app_build" <<'PY'
  /usr/bin/python3 - "$report" "$initial_mode" "$expected_mode" "$expected_vertical_state" "$expected_horizontal_state" "$app_version" "$app_build" <<'PY'
import json
import sys
path = sys.argv[1]
app_version = sys.argv[2]
app_build = sys.argv[3]
initial_mode = sys.argv[2]
expected_mode = sys.argv[3]
expected_vertical_state = sys.argv[4]
expected_horizontal_state = sys.argv[5]
app_version = sys.argv[6]
app_build = sys.argv[7]
with open(path, "r", encoding="utf-8") as file:
    report = json.load(file)
@@ -231,30 +259,40 @@
        sys.exit(1)
root = report.get("rootView", {})
status_menu = report.get("statusMenu", {})
main_menu = report.get("mainMenu", {})
settings_window = report.get("settingsWindow", {})
expected_icon_kind = "verticalColumnsTarget" if initial_mode == "verticalColumns" else "horizontalMasonryTarget"
expected_label = "切换到纵栏瀑布" if initial_mode == "verticalColumns" else "切换到横栏瀑布"
require(report.get("snapshotLoaded") is True, "settings case must load the snapshot before clicking the gear")
require(report.get("quickSwitchVisible") is False, "clicking the Settings gear must close Quick Switch")
require(root.get("lastMouseCommand") == "click-settings", "debug mouse sequence must click the Settings gear")
require(root.get("settingsButtonVisible") is True, "Quick Switch root must report the Settings gear")
require(report.get("snapshotLoaded") is True, "toggle case must load the snapshot before clicking the view-mode toggle")
require(report.get("quickSwitchVisible") is True, "clicking the view-mode toggle must keep Quick Switch visible")
require(report.get("preferenceWaterfallViewMode") == expected_mode, "preference Waterfall mode must switch after toggle")
require(report.get("resolvedWaterfallViewMode") == expected_mode, "resolved Waterfall mode must switch after toggle")
require(root.get("waterfallViewMode") == expected_mode, "root view Waterfall mode must switch after toggle")
require(root.get("lastMouseCommand") == "click-view-toggle", "debug mouse sequence must click the view-mode toggle")
require(root.get("settingsButtonVisible") is False, "old Settings button must stay hidden")
require(root.get("viewModeToggleButtonVisible") is True, "Quick Switch root must report the view-mode toggle")
require(root.get("viewModeToggleCurrentMode") == expected_mode, "toggle current mode must update after click")
require(root.get("viewModeToggleTargetMode") == initial_mode, "toggle target mode must flip back to the initial mode after click")
require(root.get("viewModeToggleIconKind") == expected_icon_kind, "toggle icon must represent the next target mode after click")
require(root.get("viewModeToggleAccessibilityLabel") == expected_label, "toggle label must represent the next target mode after click")
require(status_menu.get("verticalColumnsState") == expected_vertical_state, "Vertical Columns menu state must update after toggle")
require(status_menu.get("horizontalWaterfallState") == expected_horizontal_state, "Horizontal Waterfall menu state must update after toggle")
require(main_menu.get("hasSettingsItem") is True, "main menu must expose Settings")
require(main_menu.get("settingsKeyEquivalent") == ",", "Settings must use Cmd+, key equivalent")
require(settings_window.get("exists") is True, "Settings window controller must be created")
require(settings_window.get("visible") is True, "Settings window must be visible after clicking gear")
require(settings_window.get("selectedTabLabel") == "视图", "Settings must open on the View tab")
require(settings_window.get("selectedTabIdentifier") == "view", "Settings View tab identifier must be stable")
require(settings_window.get("hasViewTab") is True, "Settings window must expose View tab")
require(settings_window.get("usesSwiftUI") is False, "Settings window must not use SwiftUI")
require(settings_window.get("waterfallViewMode") == "verticalColumns", "Settings must read the current Waterfall view mode")
require(settings_window.get("visible") is False, "view-mode toggle must not open Settings")
require(report.get("aboutVersion") == app_version, "About version must match app Info.plist")
require(report.get("aboutBuild") == app_build, "About build must match app Info.plist")
print(json.dumps({
    "case": "settings-entry",
    "case": "view-mode-toggle",
    "initialMode": initial_mode,
    "expectedMode": expected_mode,
    "quickSwitchVisible": report.get("quickSwitchVisible"),
    "lastMouseCommand": root.get("lastMouseCommand"),
    "settingsWindow": settings_window,
    "viewModeToggleIconKind": root.get("viewModeToggleIconKind"),
    "statusMenu": status_menu,
    "mainMenu": main_menu
}, indent=2, ensure_ascii=False))
PY
@@ -288,14 +326,18 @@
  stop_current_aligner
}
run_settings_case() {
  local report="$1"
  local app_version="$2"
  local app_build="$3"
run_toggle_case() {
  local initial_mode="$1"
  local expected_mode="$2"
  local report="$3"
  local vertical_state="$4"
  local horizontal_state="$5"
  local app_version="$6"
  local app_build="$7"
  stop_current_aligner
  rm -f "$report"
  /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "verticalColumns"
  /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "$initial_mode"
  "$APP/Contents/MacOS/Aligner" \
    --round0-skip-permissions \
@@ -303,12 +345,12 @@
    --round01-fixture-app-count=4 \
    --round01-fixture-windows-per-app=2 \
    --round01-disable-screenshot-refresh \
    --round01-debug-mouse-sequence="click-settings" \
    --round01-debug-mouse-sequence="click-view-toggle" \
    --round01-quick-switch-report="$report" \
    --round01-quick-switch-quit-after=1.2 &
  wait_for_settings_report "$report"
  assert_settings_report "$report" "$app_version" "$app_build"
  wait_for_toggle_report "$report" "$expected_mode"
  assert_toggle_report "$report" "$initial_mode" "$expected_mode" "$vertical_state" "$horizontal_state" "$app_version" "$app_build"
  wait "$!" || true
  stop_current_aligner
}
@@ -325,6 +367,7 @@
run_case "verticalColumns" "$REPORT_VERTICAL" "on" "off" "$APP_VERSION" "$APP_BUILD"
run_case "horizontalMasonry" "$REPORT_HORIZONTAL" "off" "on" "$APP_VERSION" "$APP_BUILD"
run_settings_case "$REPORT_SETTINGS" "$APP_VERSION" "$APP_BUILD"
run_toggle_case "verticalColumns" "horizontalMasonry" "$REPORT_TOGGLE_TO_HORIZONTAL" "off" "on" "$APP_VERSION" "$APP_BUILD"
run_toggle_case "horizontalMasonry" "verticalColumns" "$REPORT_TOGGLE_TO_VERTICAL" "on" "off" "$APP_VERSION" "$APP_BUILD"
echo "Round01.1 view-mode entry QA passed"