| C1.source/Resources/Aligner-Info.plist | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/AlignerApplicationDelegate.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/QuickSwitchRootView.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/QuickSwitchSessionController.swift | ●●●●● patch | view | raw | blame | history | |
| C1.source/Sources/Aligner/SettingsWindowController.swift | ●●●●● patch | view | raw | blame | history | |
| C3.tools/round1-view-mode-entry-qa.sh | ●●●●● 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.58</string> <string>0.0.59</string> <key>CFBundleVersion</key> <string>20260612.1249</string> <string>20260612.1439</string> <key>LSMinimumSystemVersion</key> <string>26.0</string> <key>NSHighResolutionCapable</key> C1.source/Sources/Aligner/AlignerApplicationDelegate.swift
@@ -20,10 +20,11 @@ private var round1TriggerProbe: CGEventTapTriggerProbe? private var quickSwitchSessionController: QuickSwitchSessionController? private var quickSwitchTriggerService: CGEventTapTriggerService? private var settingsWindowController: SettingsWindowController? private var isRunningQuickSwitchLifecycle = false private var statusItem: NSStatusItem? private var verticalWaterfallViewModeMenuItem: NSMenuItem? private var horizontalWaterfallViewModeMenuItem: NSMenuItem? private var verticalWaterfallViewModeMenuItems: [NSMenuItem] = [] private var horizontalWaterfallViewModeMenuItems: [NSMenuItem] = [] func applicationDidFinishLaunching(_ notification: Notification) { let bundlePathSummary = DevelopmentDiagnostics.pathSummary(Bundle.main.bundlePath) @@ -47,6 +48,7 @@ return } configureMainMenu() configureStatusItem() if !launchOptions.skipPermissions { showPermissionsIfNeeded() @@ -96,8 +98,9 @@ let menu = NSMenu() menu.delegate = self menu.addItem(menuItem(title: "About Aligner...", action: #selector(showAbout), keyEquivalent: "")) menu.addItem(menuItem(title: "Settings...", action: #selector(showSettingsFromMenu), keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(menuItem(title: "Check Permissions...", action: #selector(showPermissions), keyEquivalent: ",")) menu.addItem(menuItem(title: "Check Permissions...", action: #selector(showPermissions), keyEquivalent: "")) menu.addItem(.separator()) menu.addItem(menuItem(title: "Show Quick Switch", action: #selector(showQuickSwitchFromMenu), keyEquivalent: "")) menu.addItem(menuItem(title: "Hide Quick Switch", action: #selector(hideQuickSwitchFromMenu), keyEquivalent: "")) @@ -114,8 +117,70 @@ DevelopmentDiagnostics.log("app.statusItem.configure.end", [ "hasButton": item.button != nil, "hasAboutItem": true, "hasWaterfallViewModeItems": verticalWaterfallViewModeMenuItem != nil && horizontalWaterfallViewModeMenuItem != nil "hasSettingsItem": true, "hasWaterfallViewModeItems": !verticalWaterfallViewModeMenuItems.isEmpty && !horizontalWaterfallViewModeMenuItems.isEmpty ]) } private func configureMainMenu() { DevelopmentDiagnostics.log("app.mainMenu.configure.start") let mainMenu = NSMenu(title: "Main Menu") let appMenuItem = NSMenuItem(title: "Aligner", action: nil, keyEquivalent: "") let appMenu = NSMenu(title: "Aligner") appMenu.delegate = self appMenuItem.submenu = appMenu appMenu.addItem(menuItem(title: "About Aligner...", action: #selector(showAbout), keyEquivalent: "")) appMenu.addItem(.separator()) appMenu.addItem(menuItem(title: "Settings...", action: #selector(showSettingsFromMenu), keyEquivalent: ",")) appMenu.addItem(.separator()) appMenu.addItem(menuItem(title: "Check Permissions...", action: #selector(showPermissions), keyEquivalent: "")) appMenu.addItem(menuItem(title: "Show Quick Switch", action: #selector(showQuickSwitchFromMenu), keyEquivalent: "")) appMenu.addItem(menuItem(title: "Hide Quick Switch", action: #selector(hideQuickSwitchFromMenu), keyEquivalent: "")) appMenu.addItem(quickSwitchViewModeMenuItem()) appMenu.addItem(.separator()) appMenu.addItem(menuItem(title: "Quit Aligner", action: #selector(quit), keyEquivalent: "q")) mainMenu.addItem(appMenuItem) let editMenuItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") let editMenu = NSMenu(title: "Edit") editMenuItem.submenu = editMenu editMenu.addItem(responderMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z")) editMenu.addItem(responderMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")) editMenu.addItem(.separator()) editMenu.addItem(responderMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) editMenu.addItem(responderMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) editMenu.addItem(responderMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) editMenu.addItem(responderMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) mainMenu.addItem(editMenuItem) let viewMenuItem = NSMenuItem(title: "View", action: nil, keyEquivalent: "") let viewMenu = NSMenu(title: "View") viewMenu.delegate = self viewMenuItem.submenu = viewMenu viewMenu.addItem(quickSwitchViewModeMenuItem()) mainMenu.addItem(viewMenuItem) let windowMenuItem = NSMenuItem(title: "Window", action: nil, keyEquivalent: "") let windowMenu = NSMenu(title: "Window") windowMenuItem.submenu = windowMenu windowMenu.addItem(responderMenuItem(title: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")) windowMenu.addItem(responderMenuItem(title: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")) windowMenu.addItem(.separator()) windowMenu.addItem(responderMenuItem(title: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")) mainMenu.addItem(windowMenuItem) NSApp.windowsMenu = windowMenu let helpMenuItem = NSMenuItem(title: "Help", action: nil, keyEquivalent: "") helpMenuItem.submenu = NSMenu(title: "Help") mainMenu.addItem(helpMenuItem) NSApp.mainMenu = mainMenu updateWaterfallViewModeMenuState() DevelopmentDiagnostics.log("app.mainMenu.configure.end", [ "hasSettingsItem": appMenu.item(withTitle: "Settings...") != nil, "hasQuickSwitchViewSubmenu": appMenu.item(withTitle: "Quick Switch View")?.submenu != nil ]) } @@ -127,6 +192,10 @@ let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) item.target = self return item } private func responderMenuItem(title: String, action: Selector?, keyEquivalent: String) -> NSMenuItem { NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) } private func quickSwitchViewModeMenuItem() -> NSMenuItem { @@ -144,8 +213,8 @@ submenu.addItem(verticalItem) submenu.addItem(horizontalItem) verticalWaterfallViewModeMenuItem = verticalItem horizontalWaterfallViewModeMenuItem = horizontalItem verticalWaterfallViewModeMenuItems.append(verticalItem) horizontalWaterfallViewModeMenuItems.append(horizontalItem) let rootItem = NSMenuItem(title: "Quick Switch View", action: nil, keyEquivalent: "") rootItem.submenu = submenu @@ -154,8 +223,12 @@ private func updateWaterfallViewModeMenuState() { let mode = quickSwitchWaterfallViewMode() verticalWaterfallViewModeMenuItem?.state = mode == .verticalColumns ? .on : .off horizontalWaterfallViewModeMenuItem?.state = mode == .horizontalMasonry ? .on : .off for item in verticalWaterfallViewModeMenuItems { item.state = mode == .verticalColumns ? .on : .off } for item in horizontalWaterfallViewModeMenuItems { item.state = mode == .horizontalMasonry ? .on : .off } } private func showPermissionsIfNeeded() { @@ -421,6 +494,9 @@ controller.onSnapshotUpdated = { [weak self] in self?.writeQuickSwitchReportIfNeeded() } controller.onOpenSettings = { [weak self] in self?.showSettingsFromQuickSwitch() } quickSwitchSessionController = controller } @@ -497,6 +573,9 @@ controller.onSnapshotUpdated = { [weak self] in self?.writeQuickSwitchReportIfNeeded() } controller.onOpenSettings = { [weak self] in self?.showSettingsFromQuickSwitch() } quickSwitchSessionController = controller } @@ -531,6 +610,8 @@ report["aboutVersion"] = versionBuild.version report["aboutBuild"] = versionBuild.build report["statusMenu"] = statusMenuReportDictionary() report["mainMenu"] = mainMenuReportDictionary() report["settingsWindow"] = settingsWindowReportDictionary() do { let data = try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]) @@ -652,6 +733,7 @@ preferenceStore.write(updatedPreferences) quickSwitchSessionController?.setWaterfallViewMode(mode) updateWaterfallViewModeMenuState() settingsWindowController?.refreshWaterfallViewMode() writeQuickSwitchReportIfNeeded() DevelopmentDiagnostics.log("app.quickSwitch.waterfallViewModePreferenceChanged", [ "mode": mode.rawValue, @@ -668,13 +750,44 @@ private func statusMenuReportDictionary() -> [String: Any] { updateWaterfallViewModeMenuState() let verticalItem = verticalWaterfallViewModeMenuItems.first let horizontalItem = horizontalWaterfallViewModeMenuItems.first return [ "hasAboutItem": statusItem?.menu?.item(withTitle: "About Aligner...") != nil, "hasSettingsItem": statusItem?.menu?.item(withTitle: "Settings...") != nil, "hasQuickSwitchViewSubmenu": statusItem?.menu?.item(withTitle: "Quick Switch View")?.submenu != nil, "hasVerticalColumnsItem": verticalWaterfallViewModeMenuItem != nil, "hasHorizontalWaterfallItem": horizontalWaterfallViewModeMenuItem != nil, "verticalColumnsState": verticalWaterfallViewModeMenuItem?.state == .on ? "on" : "off", "horizontalWaterfallState": horizontalWaterfallViewModeMenuItem?.state == .on ? "on" : "off" "hasVerticalColumnsItem": verticalItem != nil, "hasHorizontalWaterfallItem": horizontalItem != nil, "verticalColumnsState": verticalItem?.state == .on ? "on" : "off", "horizontalWaterfallState": horizontalItem?.state == .on ? "on" : "off" ] } private func mainMenuReportDictionary() -> [String: Any] { updateWaterfallViewModeMenuState() let appMenu = NSApp.mainMenu?.items.first?.submenu let viewMenu = NSApp.mainMenu?.item(withTitle: "View")?.submenu return [ "hasMenu": NSApp.mainMenu != nil, "hasAppMenu": appMenu != nil, "hasAboutItem": appMenu?.item(withTitle: "About Aligner...") != nil, "hasSettingsItem": appMenu?.item(withTitle: "Settings...") != nil, "settingsKeyEquivalent": appMenu?.item(withTitle: "Settings...")?.keyEquivalent ?? "", "hasQuickSwitchViewSubmenuInAppMenu": appMenu?.item(withTitle: "Quick Switch View")?.submenu != nil, "hasQuickSwitchViewSubmenuInViewMenu": viewMenu?.item(withTitle: "Quick Switch View")?.submenu != nil ] } private func settingsWindowReportDictionary() -> [String: Any] { settingsWindowController?.reportDictionary() ?? [ "exists": false, "visible": false, "title": "", "selectedTabLabel": "", "selectedTabIdentifier": "", "waterfallViewMode": quickSwitchWaterfallViewMode().rawValue, "hasViewTab": false, "usesSwiftUI": false ] } @@ -696,6 +809,43 @@ alert.runModal() } @objc private func showSettingsFromMenu() { openSettingsWindow(source: "menu") } private func showSettingsFromQuickSwitch() { openSettingsWindow(source: "quickSwitch") } private func openSettingsWindow(source: String) { DevelopmentDiagnostics.log("app.settings.show", [ "source": source, "quickSwitchVisible": quickSwitchSessionController?.isVisible ?? false, "mode": quickSwitchWaterfallViewMode().rawValue ]) hideQuickSwitchSession(reason: .userClosed) NSApp.setActivationPolicy(.regular) if settingsWindowController == nil { settingsWindowController = SettingsWindowController( modeProvider: { [weak self] in self?.quickSwitchWaterfallViewMode() ?? .verticalColumns }, onWaterfallViewModeChanged: { [weak self] mode in self?.setQuickSwitchWaterfallViewMode(mode) } ) } settingsWindowController?.showWindow(nil) NSApp.activate(ignoringOtherApps: true) writeQuickSwitchReportIfNeeded() DevelopmentDiagnostics.log("app.settings.windowShown", [ "windowVisible": settingsWindowController?.window?.isVisible ?? false, "activationPolicy": NSApp.activationPolicy().rawValue ]) } @objc private func useVerticalWaterfallViewMode() { setQuickSwitchWaterfallViewMode(.verticalColumns) } C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -24,6 +24,9 @@ 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 appIconProvider: any AppIconProviderProtocol = WorkspaceAppIconProvider() private let skeletonThumbnailProvider = NativeSkeletonThumbnailProvider(size: NSSize(width: 160, height: 96)) private var screenshotSourcesByWindowID: [UInt32: ScreenshotResolutionSource] = [:] @@ -49,6 +52,7 @@ private var closeConfirmationRequired = true private var closeFeedback: CloseFeedback? private var closeFeedbackHideTask: Task<Void, Never>? private var isSettingsButtonHovered = false private var hoverTarget: HoverTarget = .none private var hoverTargetSource: HoverTargetSource = .event private var hoveredSpaceLaneID: UInt64? @@ -91,6 +95,7 @@ var onCommitSelection: ((QuickSwitchSelection, QuickSwitchCommitSource) -> Void)? var onSpaceLaneClick: ((UInt64, Int) -> Void)? var onBackgroundClick: (() -> Bool)? var onOpenSettings: (() -> Void)? var onRequestClose: ((QuickSwitchCloseRequest) -> Void)? var onCloseConfirmationDisabled: (() -> Void)? @@ -481,6 +486,17 @@ override func mouseMoved(with event: NSEvent) { guard debugHoveredAppGroupIndex == nil else { return } let settingsHovered = settingsButtonContains(windowLocation: event.locationInWindow) if isSettingsButtonHovered != settingsHovered { isSettingsButtonHovered = settingsHovered needsLayout = true } if settingsHovered { hoveredCloseTarget = nil setHoverTarget(.none) return } let closeTarget = closeButtonTarget(at: event.locationInWindow) if hoveredCloseTarget != closeTarget { hoveredCloseTarget = closeTarget @@ -491,6 +507,7 @@ override func mouseExited(with event: NSEvent) { guard debugHoveredAppGroupIndex == nil else { return } isSettingsButtonHovered = false hoveredCloseTarget = nil setHoverTarget(.none) } @@ -506,6 +523,11 @@ } if handleCloseConfirmationMouseDown(at: event.locationInWindow) { return } if settingsButtonContains(windowLocation: event.locationInWindow) { clickSettingsButton(source: "mouse") return } @@ -1060,6 +1082,9 @@ "activeSpaceFocusID": activeSpaceFocusID ?? NSNull(), "spaceFilterLockedSpaceID": currentViewModel?.lockedSpaceID ?? NSNull(), "spaceFilterActive": currentViewModel?.lockedSpaceID != nil, "settingsButtonVisible": !settingsButtonLayer.isHidden, "settingsButtonHovered": isSettingsButtonHovered, "settingsButtonFrame": dictionary(from: settingsButtonLayer.frame), "spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(), "appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(), "appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels, @@ -1302,6 +1327,9 @@ case "click-background": recordMouseCommand("click-background") _ = onBackgroundClick?() case "click-settings": recordMouseCommand("click-settings") clickSettingsButton(source: "debug") case "hover-card" where parts.count == 3: guard let appGroupIndex = Int(parts[1]), let windowIndex = Int(parts[2]) else { recordMouseCommand("invalid:\(command)") @@ -2244,6 +2272,26 @@ closeFeedbackTextLayer.truncationMode = .end 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) } private func rebuildAppShelfItems() { @@ -2581,8 +2629,40 @@ layoutWaterfallColumns() layoutCloseConfirmation() layoutCloseFeedback() layoutSettingsButton() CATransaction.commit() } private func layoutSettingsButton() { let size: CGFloat = 34 let topMargin = topSafeAreaInset + RootLayoutMetrics.topContentMargin let x = max( RootLayoutMetrics.horizontalMargin, glassLayer.bounds.width - RootLayoutMetrics.horizontalMargin - size ) let y = max( 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) .cgColor settingsButtonLayer.borderColor = NSColor.separatorColor .withAlphaComponent(isSettingsButtonHovered ? 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 : NSColor.secondaryLabelColor ).cgColor } private var topSafeAreaInset: CGFloat { @@ -2896,6 +2976,26 @@ private func spaceLaneSegment(spaceID: UInt64) -> SpaceLaneSegmentLayers? { spaceLaneSegments.first { $0.space.id == spaceID } } private func settingsButtonContains(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) } private func clickSettingsButton(source: String) { DevelopmentDiagnostics.log("quickSwitch.view.clickSettings", [ "source": source ]) lastMouseCommand = "click-settings" if !mouseCommandsApplied.contains("click-settings") { mouseCommandsApplied.append("click-settings") } onOpenSettings?() } private func appShelfItem(at windowLocation: CGPoint) -> AppShelfItemLayers? { @@ -4381,6 +4481,21 @@ 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( string: String, fontSize: CGFloat, C1.source/Sources/Aligner/QuickSwitchSessionController.swift
@@ -86,6 +86,7 @@ private var lifecycleResidualOverlayWindows = 0 private var lifecycleResidualVisibleOverlayWindows = 0 var onSnapshotUpdated: (() -> Void)? var onOpenSettings: (() -> Void)? private struct PendingCloseVerification { let request: QuickSwitchCloseRequest @@ -212,6 +213,9 @@ view.onBackgroundClick = { [weak self] in self?.handleBackgroundClick() ?? false } view.onOpenSettings = { [weak self] in self?.onOpenSettings?() } view.setSuppressEventInput(!debugMouseSequence.isEmpty || !debugKeySequence.isEmpty) view.onRequestClose = { [weak self] request in self?.requestClose(request) C1.source/Sources/Aligner/SettingsWindowController.swift
New file @@ -0,0 +1,138 @@ import AppKit import AlignerCore @MainActor final class SettingsWindowController: NSWindowController { private let modeProvider: () -> QuickSwitchWaterfallViewMode private let onWaterfallViewModeChanged: (QuickSwitchWaterfallViewMode) -> Void private let tabView = NSTabView() private let verticalColumnsButton = NSButton( radioButtonWithTitle: "纵栏瀑布", target: nil, action: nil ) private let horizontalMasonryButton = NSButton( radioButtonWithTitle: "横栏瀑布", target: nil, action: nil ) init( modeProvider: @escaping () -> QuickSwitchWaterfallViewMode, onWaterfallViewModeChanged: @escaping (QuickSwitchWaterfallViewMode) -> Void ) { self.modeProvider = modeProvider self.onWaterfallViewModeChanged = onWaterfallViewModeChanged let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, height: 340), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false ) window.title = "Aligner 设置" window.isReleasedWhenClosed = false window.center() super.init(window: window) window.contentView = makeContentView() refreshWaterfallViewMode() } @available(*, unavailable) required init?(coder: NSCoder) { nil } override func showWindow(_ sender: Any?) { refreshWaterfallViewMode() tabView.selectTabViewItem(withIdentifier: "view") super.showWindow(sender) window?.makeKeyAndOrderFront(sender) } func refreshWaterfallViewMode() { let mode = modeProvider() verticalColumnsButton.state = mode == .verticalColumns ? .on : .off horizontalMasonryButton.state = mode == .horizontalMasonry ? .on : .off } func reportDictionary() -> [String: Any] { [ "exists": true, "visible": window?.isVisible ?? false, "title": window?.title ?? "", "selectedTabLabel": tabView.selectedTabViewItem?.label ?? "", "selectedTabIdentifier": tabView.selectedTabViewItem?.identifier as? String ?? "", "waterfallViewMode": modeProvider().rawValue, "hasViewTab": tabView.tabViewItems.contains { $0.identifier as? String == "view" }, "usesSwiftUI": false ] } private func makeContentView() -> NSView { tabView.translatesAutoresizingMaskIntoConstraints = false tabView.tabViewType = .topTabsBezelBorder tabView.addTabViewItem(makeViewTab()) let root = NSView() root.addSubview(tabView) NSLayoutConstraint.activate([ tabView.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20), tabView.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20), tabView.topAnchor.constraint(equalTo: root.topAnchor, constant: 18), tabView.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20) ]) return root } private func makeViewTab() -> NSTabViewItem { let tab = NSTabViewItem(identifier: "view") tab.label = "视图" tab.view = makeViewSettingsView() return tab } private func makeViewSettingsView() -> NSView { let title = NSTextField(labelWithString: "Quick Switch 视图") title.font = .systemFont(ofSize: 18, weight: .semibold) let subtitle = NSTextField(wrappingLabelWithString: "切换后立即生效。") subtitle.textColor = .secondaryLabelColor verticalColumnsButton.target = self verticalColumnsButton.action = #selector(selectVerticalColumns) horizontalMasonryButton.target = self horizontalMasonryButton.action = #selector(selectHorizontalMasonry) let modeStack = NSStackView(views: [verticalColumnsButton, horizontalMasonryButton]) modeStack.orientation = .vertical modeStack.alignment = .leading modeStack.spacing = 10 let stack = NSStackView(views: [title, subtitle, modeStack]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 14 stack.translatesAutoresizingMaskIntoConstraints = false let view = NSView() view.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 22), stack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -22), stack.topAnchor.constraint(equalTo: view.topAnchor, constant: 24) ]) return view } @objc private func selectVerticalColumns() { onWaterfallViewModeChanged(.verticalColumns) refreshWaterfallViewMode() } @objc private func selectHorizontalMasonry() { onWaterfallViewModeChanged(.horizontalMasonry) refreshWaterfallViewMode() } } C3.tools/round1-view-mode-entry-qa.sh
@@ -12,6 +12,7 @@ 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" DOMAIN="com.ar.Aligner" WATERFALL_MODE_KEY="com.ar.Aligner.preferences.quickSwitch.waterfallViewMode" REPORT_WAIT="${ALIGNER_ROUND1_VIEW_MODE_ENTRY_REPORT_WAIT:-6.0}" @@ -131,6 +132,8 @@ sys.exit(1) status_menu = report.get("statusMenu", {}) main_menu = report.get("mainMenu", {}) settings_window = report.get("settingsWindow", {}) root = report.get("rootView", {}) require(report.get("snapshotLoaded") is True, "snapshotLoaded must be true") @@ -139,11 +142,23 @@ require(report.get("resolvedWaterfallViewMode") == expected_mode, "resolved Waterfall mode must match expected mode") require(root.get("waterfallViewMode") == expected_mode, "root view Waterfall mode must match expected mode") require(status_menu.get("hasAboutItem") is True, "status menu must expose About Aligner") require(status_menu.get("hasSettingsItem") is True, "status menu must expose Settings") require(status_menu.get("hasQuickSwitchViewSubmenu") is True, "status menu must expose Quick Switch View submenu") require(status_menu.get("hasVerticalColumnsItem") is True, "status menu must expose Vertical Columns item") require(status_menu.get("hasHorizontalWaterfallItem") is True, "status menu must expose Horizontal Waterfall item") require(status_menu.get("verticalColumnsState") == expected_vertical_state, "Vertical Columns check state must match expected mode") require(status_menu.get("horizontalWaterfallState") == expected_horizontal_state, "Horizontal Waterfall check state must match expected mode") require(main_menu.get("hasMenu") is True, "main menu must exist") require(main_menu.get("hasAppMenu") is True, "main menu must expose app menu") require(main_menu.get("hasSettingsItem") is True, "main menu must expose Settings") 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(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") require(re.match(r"^[0-9]+\.[0-9]+\.[0-9]+$", app_version) is not None, "version must be MAJOR.MINOR.PATCH") @@ -153,7 +168,94 @@ "mode": expected_mode, "aboutVersion": report.get("aboutVersion"), "aboutBuild": report.get("aboutBuild"), "statusMenu": status_menu "statusMenu": status_menu, "mainMenu": main_menu, "settingsButtonFrame": settings_frame }, indent=2, ensure_ascii=False)) PY } wait_for_settings_report() { local report="$1" /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("settingsWindow", {}).get("visible") 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"settings report did not become visible within {timeout:.1f}s", file=sys.stderr) sys.exit(1) PY } assert_settings_report() { local report="$1" local app_version="$2" local app_build="$3" /usr/bin/python3 - "$report" "$app_version" "$app_build" <<'PY' import json import sys path = sys.argv[1] app_version = sys.argv[2] app_build = sys.argv[3] 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) root = report.get("rootView", {}) main_menu = report.get("mainMenu", {}) settings_window = report.get("settingsWindow", {}) 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(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(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", "quickSwitchVisible": report.get("quickSwitchVisible"), "lastMouseCommand": root.get("lastMouseCommand"), "settingsWindow": settings_window, "mainMenu": main_menu }, indent=2, ensure_ascii=False)) PY } @@ -186,6 +288,31 @@ stop_current_aligner } run_settings_case() { local report="$1" local app_version="$2" local app_build="$3" stop_current_aligner rm -f "$report" /usr/bin/defaults write "$DOMAIN" "$WATERFALL_MODE_KEY" -string "verticalColumns" "$APP/Contents/MacOS/Aligner" \ --round0-skip-permissions \ --round01-open-quick-switch \ --round01-fixture-app-count=4 \ --round01-fixture-windows-per-app=2 \ --round01-disable-screenshot-refresh \ --round01-debug-mouse-sequence="click-settings" \ --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 "$!" || true stop_current_aligner } [ -x "$APP/Contents/MacOS/Aligner" ] || fail "missing executable app at $APP" INFO_PLIST="$APP/Contents/Info.plist" APP_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$INFO_PLIST" 2>/dev/null)" \ @@ -198,5 +325,6 @@ 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" echo "Round01.1 view-mode entry QA passed"