From 0045898e8cd7b2d8972bb24c8ac27e514bdd8c70 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 11 Jun 2026 16:46:31 +0800
Subject: [PATCH] Implement Quick Switch Space filtering
---
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 519 ++++++++++++++++++++++++++++++++++++++++++++++++++-------
1 files changed, 451 insertions(+), 68 deletions(-)
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 0b41426..dba70d2 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
@@ -78,8 +78,13 @@
private var keyboardResponseLatencyMilliseconds: [Double] = []
private var mouseResponseLatencyMilliseconds: [Double] = []
private var hoverResponseLatencyMilliseconds: [Double] = []
+ private var lastProjectionTransitionMovedLayerCount = 0
+ private var lastProjectionTransitionFadeInLayerCount = 0
+ private var lastProjectionTransitionFadeOutLayerCount = 0
+ private var lastProjectionTransitionDurationMilliseconds: Double = 0
var onEscape: (() -> Void)?
var onCommitSelection: ((QuickSwitchSelection, QuickSwitchCommitSource) -> Void)?
+ var onSpaceLaneClick: ((UInt64) -> Void)?
var onRequestClose: ((QuickSwitchCloseRequest) -> Void)?
var onCloseConfirmationDisabled: (() -> Void)?
@@ -256,6 +261,22 @@
let stateLayer: CATextLayer
let shineLayer: CAGradientLayer
let closeButton: CloseButtonLayers
+ }
+
+ private struct ProjectionLayerSnapshot {
+ let layer: CALayer
+ let frame: CGRect
+ }
+
+ private struct ProjectionColumnSnapshot {
+ let layer: CALayer
+ let frame: CGRect
+ let cardsByWindowID: [UInt32: ProjectionLayerSnapshot]
+ }
+
+ private struct ProjectionTransitionSnapshot {
+ let appShelfItemsByAppGroupIndex: [Int: ProjectionLayerSnapshot]
+ let waterfallColumnsByAppGroupIndex: [Int: ProjectionColumnSnapshot]
}
private enum AppShelfMetrics {
@@ -608,35 +629,73 @@
}
}
+ func effectiveSelectionForProjection() -> QuickSwitchSelection? {
+ effectiveSelection
+ }
+
func apply(viewModel: QuickSwitchViewModel?) {
+ apply(viewModel: viewModel, preservingInteractionState: false, animatedProjection: false)
+ }
+
+ func applyProjected(viewModel: QuickSwitchViewModel?) {
+ apply(viewModel: viewModel, preservingInteractionState: true, animatedProjection: true)
+ }
+
+ private func apply(
+ viewModel: QuickSwitchViewModel?,
+ preservingInteractionState: Bool,
+ animatedProjection: Bool
+ ) {
+ let transitionSnapshot = animatedProjection ? captureProjectionTransitionSnapshot() : nil
+ if !animatedProjection {
+ resetProjectionTransitionReport()
+ }
currentViewModel = viewModel
- screenshotSourcesByWindowID = [:]
- screenshotSkippedReasonsByWindowID = [:]
- currentSelection = viewModel?.initialSelection
- columnSelectionHistory = [:]
- keyboardCommandsApplied = []
- lastKeyboardCommand = nil
- tabIgnoredCount = 0
- lastCommittedSelection = nil
- lastCommitSource = nil
- mouseCommandsApplied = []
- lastMouseCommand = nil
- lastClickedAppGroupIndex = nil
- lastAppShelfClickChangedSelection = nil
- lastClickedWindowID = nil
- lastSpaceFocusedAppGroupIndex = nil
- lastSpaceLaneClickSpaceID = nil
- lastAppShelfScrollSyncedAppGroupIndex = nil
- focusedSpaceID = nil
- hoveredCloseTarget = nil
- pendingCloseTarget = nil
- pendingCloseRequest = nil
- pendingCloseOptOut = false
- closeConfirmationLayers.containerLayer.isHidden = true
- closeConfirmationArrowEdge = nil
- clearCloseFeedback()
- setHoverTarget(.none)
- hoveredAppGroupIndex = debugHoveredAppGroupIndex
+
+ if preservingInteractionState {
+ currentSelection = validSelection(currentSelection, in: viewModel) ?? viewModel?.initialSelection
+ columnSelectionHistory = columnSelectionHistory.filter { appGroupIndex, _ in
+ viewModel?.waterfallColumns.contains { $0.appGroupIndex == appGroupIndex } == true
+ }
+ if let currentSelection {
+ columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
+ }
+ hoveredCloseTarget = nil
+ pendingCloseTarget = nil
+ pendingCloseRequest = nil
+ pendingCloseOptOut = false
+ closeConfirmationLayers.containerLayer.isHidden = true
+ closeConfirmationArrowEdge = nil
+ } else {
+ screenshotSourcesByWindowID = [:]
+ screenshotSkippedReasonsByWindowID = [:]
+ currentSelection = viewModel?.initialSelection
+ columnSelectionHistory = [:]
+ keyboardCommandsApplied = []
+ lastKeyboardCommand = nil
+ tabIgnoredCount = 0
+ lastCommittedSelection = nil
+ lastCommitSource = nil
+ mouseCommandsApplied = []
+ lastMouseCommand = nil
+ lastClickedAppGroupIndex = nil
+ lastAppShelfClickChangedSelection = nil
+ lastClickedWindowID = nil
+ lastSpaceFocusedAppGroupIndex = nil
+ lastSpaceLaneClickSpaceID = nil
+ lastAppShelfScrollSyncedAppGroupIndex = nil
+ focusedSpaceID = nil
+ hoveredCloseTarget = nil
+ pendingCloseTarget = nil
+ pendingCloseRequest = nil
+ pendingCloseOptOut = false
+ closeConfirmationLayers.containerLayer.isHidden = true
+ closeConfirmationArrowEdge = nil
+ clearCloseFeedback()
+ setHoverTarget(.none)
+ hoveredAppGroupIndex = debugHoveredAppGroupIndex
+ }
+
if let currentSelection {
columnSelectionHistory[currentSelection.appGroupIndex] = currentSelection.windowIndex
}
@@ -646,6 +705,250 @@
needsLayout = true
ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: false))
needsLayout = true
+ if animatedProjection, let transitionSnapshot {
+ layoutSubtreeIfNeeded()
+ applyProjectionTransitionAnimations(from: transitionSnapshot)
+ }
+ }
+
+ private func validSelection(
+ _ selection: QuickSwitchSelection?,
+ in viewModel: QuickSwitchViewModel?
+ ) -> QuickSwitchSelection? {
+ guard let selection, let viewModel else { return nil }
+
+ for column in viewModel.waterfallColumns {
+ if let card = column.windows.first(where: { $0.window.id == selection.windowID }) {
+ return QuickSwitchSelection(
+ appGroupIndex: card.appGroupIndex,
+ windowIndex: card.windowIndex,
+ windowID: card.window.id
+ )
+ }
+ }
+
+ return nil
+ }
+
+ private func captureProjectionTransitionSnapshot() -> ProjectionTransitionSnapshot {
+ ProjectionTransitionSnapshot(
+ appShelfItemsByAppGroupIndex: Dictionary(
+ uniqueKeysWithValues: appShelfItems.map { item in
+ (
+ item.item.appGroupIndex,
+ ProjectionLayerSnapshot(
+ layer: item.containerLayer,
+ frame: item.containerLayer.frame
+ )
+ )
+ }
+ ),
+ waterfallColumnsByAppGroupIndex: Dictionary(
+ uniqueKeysWithValues: waterfallColumns.map { column in
+ (
+ column.column.appGroupIndex,
+ ProjectionColumnSnapshot(
+ layer: column.containerLayer,
+ frame: column.containerLayer.frame,
+ cardsByWindowID: Dictionary(
+ uniqueKeysWithValues: column.cards.map { card in
+ (
+ card.item.window.id,
+ ProjectionLayerSnapshot(
+ layer: card.containerLayer,
+ frame: card.containerLayer.frame
+ )
+ )
+ }
+ )
+ )
+ )
+ }
+ )
+ )
+ }
+
+ private func applyProjectionTransitionAnimations(from snapshot: ProjectionTransitionSnapshot) {
+ let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
+ let duration: CFTimeInterval = reduceMotion ? 0.12 : 0.21
+ let timing = CAMediaTimingFunction(name: reduceMotion ? .linear : .easeOut)
+ var movedLayerCount = 0
+ var fadeInLayerCount = 0
+ var fadeOutLayerCount = 0
+
+ for item in appShelfItems {
+ guard let oldSnapshot = snapshot.appShelfItemsByAppGroupIndex[item.item.appGroupIndex] else {
+ if addFadeInAnimation(to: item.containerLayer, duration: duration, timing: timing) {
+ fadeInLayerCount += 1
+ }
+ continue
+ }
+ if addPositionAnimation(to: item.containerLayer, from: oldSnapshot.frame, duration: duration, timing: timing) {
+ movedLayerCount += 1
+ }
+ }
+
+ let visibleAppGroupIndexes = Set(appShelfItems.map(\.item.appGroupIndex))
+ for (appGroupIndex, oldSnapshot) in snapshot.appShelfItemsByAppGroupIndex
+ where !visibleAppGroupIndexes.contains(appGroupIndex) {
+ if addFadeOutSnapshot(
+ oldSnapshot,
+ to: appShelfContentLayer,
+ frame: oldSnapshot.frame,
+ duration: duration,
+ timing: timing
+ ) {
+ fadeOutLayerCount += 1
+ }
+ }
+
+ for column in waterfallColumns {
+ guard let oldSnapshot = snapshot.waterfallColumnsByAppGroupIndex[column.column.appGroupIndex] else {
+ if addFadeInAnimation(to: column.containerLayer, duration: duration, timing: timing) {
+ fadeInLayerCount += 1
+ }
+ continue
+ }
+ if addPositionAnimation(to: column.containerLayer, from: oldSnapshot.frame, duration: duration, timing: timing) {
+ movedLayerCount += 1
+ }
+
+ for card in column.cards {
+ guard let oldCardSnapshot = oldSnapshot.cardsByWindowID[card.item.window.id] else {
+ if addFadeInAnimation(to: card.containerLayer, duration: duration, timing: timing) {
+ fadeInLayerCount += 1
+ }
+ continue
+ }
+ if addPositionAnimation(to: card.containerLayer, from: oldCardSnapshot.frame, duration: duration, timing: timing) {
+ movedLayerCount += 1
+ }
+ }
+ }
+
+ let visibleColumnsByAppGroupIndex = Dictionary(
+ uniqueKeysWithValues: waterfallColumns.map { ($0.column.appGroupIndex, $0) }
+ )
+ for (appGroupIndex, oldSnapshot) in snapshot.waterfallColumnsByAppGroupIndex {
+ guard let visibleColumn = visibleColumnsByAppGroupIndex[appGroupIndex] else {
+ if addFadeOutSnapshot(
+ ProjectionLayerSnapshot(layer: oldSnapshot.layer, frame: oldSnapshot.frame),
+ to: waterfallContentLayer,
+ frame: oldSnapshot.frame,
+ duration: duration,
+ timing: timing
+ ) {
+ fadeOutLayerCount += 1
+ }
+ continue
+ }
+
+ let visibleWindowIDs = Set(visibleColumn.cards.map(\.item.window.id))
+ for (windowID, oldCardSnapshot) in oldSnapshot.cardsByWindowID
+ where !visibleWindowIDs.contains(windowID) {
+ let absoluteFrame = oldCardSnapshot.frame.offsetBy(
+ dx: oldSnapshot.frame.minX,
+ dy: oldSnapshot.frame.minY
+ )
+ if addFadeOutSnapshot(
+ oldCardSnapshot,
+ to: waterfallContentLayer,
+ frame: absoluteFrame,
+ duration: duration,
+ timing: timing
+ ) {
+ fadeOutLayerCount += 1
+ }
+ }
+ }
+
+ lastProjectionTransitionMovedLayerCount = movedLayerCount
+ lastProjectionTransitionFadeInLayerCount = fadeInLayerCount
+ lastProjectionTransitionFadeOutLayerCount = fadeOutLayerCount
+ lastProjectionTransitionDurationMilliseconds = duration * 1_000
+ }
+
+ private func resetProjectionTransitionReport() {
+ lastProjectionTransitionMovedLayerCount = 0
+ lastProjectionTransitionFadeInLayerCount = 0
+ lastProjectionTransitionFadeOutLayerCount = 0
+ lastProjectionTransitionDurationMilliseconds = 0
+ }
+
+ @discardableResult
+ private func addPositionAnimation(
+ to layer: CALayer,
+ from oldFrame: CGRect,
+ duration: CFTimeInterval,
+ timing: CAMediaTimingFunction
+ ) -> Bool {
+ let oldPosition = CGPoint(x: oldFrame.midX, y: oldFrame.midY)
+ let newPosition = layer.position
+ guard abs(oldPosition.x - newPosition.x) > 0.5 || abs(oldPosition.y - newPosition.y) > 0.5 else {
+ return false
+ }
+
+ let animation = CABasicAnimation(keyPath: "position")
+ animation.fromValue = oldPosition
+ animation.toValue = newPosition
+ animation.duration = duration
+ animation.timingFunction = timing
+ animation.isRemovedOnCompletion = true
+ layer.add(animation, forKey: "quickSwitchProjectionPosition")
+ return true
+ }
+
+ @discardableResult
+ private func addFadeInAnimation(
+ to layer: CALayer,
+ duration: CFTimeInterval,
+ timing: CAMediaTimingFunction
+ ) -> Bool {
+ let animation = CABasicAnimation(keyPath: "opacity")
+ animation.fromValue = 0
+ animation.toValue = layer.opacity
+ animation.duration = duration
+ animation.timingFunction = timing
+ animation.isRemovedOnCompletion = true
+ layer.add(animation, forKey: "quickSwitchProjectionFadeIn")
+ return true
+ }
+
+ @discardableResult
+ private func addFadeOutSnapshot(
+ _ snapshot: ProjectionLayerSnapshot,
+ to parentLayer: CALayer,
+ frame: CGRect,
+ duration: CFTimeInterval,
+ timing: CAMediaTimingFunction
+ ) -> Bool {
+ let layer = snapshot.layer
+ layer.removeAllAnimations()
+ layer.removeFromSuperlayer()
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+ layer.frame = frame
+ layer.opacity = max(layer.opacity, 0.01)
+ layer.zPosition = max(layer.zPosition, 240)
+ parentLayer.addSublayer(layer)
+ CATransaction.commit()
+
+ let animation = CABasicAnimation(keyPath: "opacity")
+ animation.fromValue = layer.opacity
+ animation.toValue = 0
+ animation.duration = duration
+ animation.timingFunction = timing
+ animation.isRemovedOnCompletion = true
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+ layer.opacity = 0
+ CATransaction.commit()
+ layer.add(animation, forKey: "quickSwitchProjectionFadeOut")
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.05) { [weak layer] in
+ layer?.removeFromSuperlayer()
+ }
+ return true
}
func reportDictionary() -> [String: Any] {
@@ -685,6 +988,8 @@
"spaceLaneHoveredSpaceID": hoveredSpaceLaneID ?? NSNull(),
"spaceLaneFocusedSpaceID": focusedSpaceID ?? NSNull(),
"activeSpaceFocusID": activeSpaceFocusID ?? NSNull(),
+ "spaceFilterLockedSpaceID": currentViewModel?.lockedSpaceID ?? NSNull(),
+ "spaceFilterActive": currentViewModel?.lockedSpaceID != nil,
"spaceFocusAppGroupIndexes": spaceFocusedAppGroupIndexes(),
"appHoverAssociatedSpaceIDs": appHoverAssociatedSpaceIDs.sorted(),
"appHoverAssociatedSpaceLabels": appHoverAssociatedSpaceLabels,
@@ -722,7 +1027,8 @@
"waterfallMaxScrollOffset": Double(waterfallMaxScrollOffset),
"waterfallAlignmentMinScrollOffset": Double(waterfallAlignmentScrollRange.lowerBound),
"waterfallAlignmentMaxScrollOffset": Double(waterfallAlignmentScrollRange.upperBound),
- "waterfallScrollable": waterfallAlignmentScrollable,
+ "waterfallScrollable": waterfallMaxScrollOffset > 0,
+ "waterfallAlignmentScrollable": waterfallAlignmentScrollable,
"waterfallColumnCount": waterfallColumns.count,
"waterfallColumnNames": waterfallColumns.map(\.column.app.name),
"waterfallClipsToBounds": waterfallLayer.masksToBounds,
@@ -731,6 +1037,10 @@
"waterfallAlignmentTolerance": Double(WaterfallAlignmentMetrics.tolerance),
"waterfallAlignmentAnimationDurationMilliseconds": Double(WaterfallAlignmentMetrics.animationDuration * 1_000),
"waterfallAlignmentAnimationTimingName": "easeOut",
+ "projectionTransitionMovedLayerCount": lastProjectionTransitionMovedLayerCount,
+ "projectionTransitionFadeInLayerCount": lastProjectionTransitionFadeInLayerCount,
+ "projectionTransitionFadeOutLayerCount": lastProjectionTransitionFadeOutLayerCount,
+ "projectionTransitionDurationMilliseconds": lastProjectionTransitionDurationMilliseconds,
"reduceMotionEffective": NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
"selectedAppGroupIndex": effectiveSelection?.appGroupIndex ?? NSNull(),
"selectedWindowIndex": effectiveSelection?.windowIndex ?? NSNull(),
@@ -1020,16 +1330,8 @@
"label": segment.space.label
])
lastSpaceLaneClickSpaceID = segment.space.id
-
- guard let firstCard = firstWaterfallCard(inSpaceID: segment.space.id) else {
- DevelopmentDiagnostics.log("quickSwitch.view.clickSpaceLane.emptySpaceUnsupported", [
- "spaceID": segment.space.id,
- "label": segment.space.label
- ])
- return
- }
-
- commitWindowCard(firstCard, trigger: "spaceLane")
+ onSpaceLaneClick?(segment.space.id)
+ lastSpaceLaneClickSpaceID = segment.space.id
}
private func focusSpace(_ spaceID: UInt64, persistent: Bool, source: HoverTargetSource = .event) {
@@ -1835,12 +2137,14 @@
groupContainer.shadowOffset = .zero
let segments = display.spaces.map { space in
- let container = CALayer()
+ let container = CAGradientLayer()
container.name = space.label
container.cornerRadius = 10
container.borderWidth = 1
container.borderColor = NSColor.separatorColor.withAlphaComponent(0.34).cgColor
- container.backgroundColor = spaceColor(for: space).cgColor
+ container.backgroundColor = NSColor.clear.cgColor
+ container.startPoint = CGPoint(x: 0.5, y: 1.0)
+ container.endPoint = CGPoint(x: 0.5, y: 0.0)
container.shadowColor = NSColor.black.cgColor
let labelLayer = makeTextLayer(
@@ -2115,6 +2419,7 @@
segment.space.id == activeSpaceFocusID
|| appHoverAssociatedSpaceIDs.contains(segment.space.id)
|| segment.space.id == windowHoverAssociatedSpaceID
+ || segment.space.id == currentViewModel?.lockedSpaceID
}
let hasCurrentSegment = group.display.spaces.contains { $0.isCurrent }
group.containerLayer.backgroundColor = NSColor.windowBackgroundColor.cgColor
@@ -2130,11 +2435,12 @@
private func applySpaceLaneVisualState(_ segment: SpaceLaneSegmentLayers) {
let isHovered = segment.space.id == activeSpaceFocusID
let isFocused = segment.space.id == activeSpaceFocusID
+ let isLocked = segment.space.id == currentViewModel?.lockedSpaceID
let isCurrent = segment.space.isCurrent
let hasWindows = segment.space.windowCount > 0
let isAppAssociated = appHoverAssociatedSpaceIDs.contains(segment.space.id)
let isWindowAssociated = segment.space.id == windowHoverAssociatedSpaceID
- let isActive = isHovered || isFocused
+ let isActive = isHovered || isFocused || isLocked
let isAssociated = (isAppAssociated || isWindowAssociated) && !isActive
segment.containerLayer.borderWidth = (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1
@@ -2148,28 +2454,46 @@
? NSColor.labelColor.withAlphaComponent(0.34)
: NSColor.separatorColor.withAlphaComponent(0.34)
).cgColor
- segment.containerLayer.backgroundColor = (isActive
- ? accentTintedWindowBackground(fraction: 0.10, alpha: 0.94)
- : isAssociated
- ? accentTintedWindowBackground(fraction: 0.07, alpha: 0.90)
- : spaceColor(for: segment.space)
- ).cgColor
- segment.containerLayer.shadowOpacity = isActive ? 0.26 : isAssociated ? 0.18 : isCurrent ? 0.14 : 0
- segment.containerLayer.shadowRadius = isActive ? 24 : isAssociated ? 18 : isCurrent ? 12 : 0
+ if !hasWindows {
+ applySpaceLaneSolidFill(to: segment.containerLayer, color: .clear)
+ } else if isActive {
+ applySpaceLaneSolidFill(
+ to: segment.containerLayer,
+ color: accentTintedWindowBackground(fraction: 0.10, alpha: 0.94)
+ )
+ } else if isAssociated {
+ applySpaceLaneSolidFill(
+ to: segment.containerLayer,
+ color: accentTintedWindowBackground(fraction: 0.07, alpha: 0.90)
+ )
+ } else if hasWindows {
+ applySpaceLaneGradientFill(
+ to: segment.containerLayer,
+ colors: spaceLaneIdleOccupiedGradientColors()
+ )
+ }
+ segment.containerLayer.shadowOpacity = (hasWindows || isLocked)
+ ? (isActive ? 0.26 : isAssociated ? 0.18 : isCurrent ? 0.14 : 0)
+ : 0
+ segment.containerLayer.shadowRadius = (hasWindows || isLocked)
+ ? (isActive ? 24 : isAssociated ? 18 : isCurrent ? 12 : 0)
+ : 0
segment.containerLayer.shadowOffset = CGSize(
width: 0,
- height: isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0
+ height: (hasWindows || isLocked) ? (isActive ? -6 : isAssociated ? -4 : isCurrent ? -2.5 : 0) : 0
)
segment.containerLayer.zPosition = isActive ? 30 : isAssociated ? 18 : isCurrent ? 10 : 0
segment.containerLayer.transform = (isActive || isAssociated)
? CATransform3DMakeScale(isActive ? 1.03 : 1.015, isActive ? 1.03 : 1.015, 1)
: CATransform3DIdentity
- segment.containerLayer.shadowPath = CGPath(
- roundedRect: segment.containerLayer.bounds,
- cornerWidth: segment.containerLayer.cornerRadius,
- cornerHeight: segment.containerLayer.cornerRadius,
- transform: nil
- )
+ segment.containerLayer.shadowPath = hasWindows
+ ? CGPath(
+ roundedRect: segment.containerLayer.bounds,
+ cornerWidth: segment.containerLayer.cornerRadius,
+ cornerHeight: segment.containerLayer.cornerRadius,
+ transform: nil
+ )
+ : nil
}
private func shouldScrollSpaceLane(for event: NSEvent) -> Bool {
@@ -3440,20 +3764,43 @@
return states.joined(separator: " / ")
}
- private func spaceColor(for space: QuickSwitchSpaceViewModel) -> NSColor {
- if space.windowCount > 0 {
- return NSColor.controlBackgroundColor.withAlphaComponent(0.36)
- }
-
- return NSColor.clear
- }
-
private var spaceLaneOccupiedColor: NSColor {
if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
return NSColor(calibratedRed: 10 / 255, green: 132 / 255, blue: 1, alpha: 1)
}
return NSColor(calibratedRed: 0, green: 122 / 255, blue: 1, alpha: 1)
+ }
+
+ private func spaceLaneIdleOccupiedGradientColors() -> [NSColor] {
+ if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
+ let blue = NSColor(calibratedRed: 36 / 255, green: 54 / 255, blue: 72 / 255, alpha: 1)
+ let surface = NSColor.windowBackgroundColor.withAlphaComponent(1)
+ return [blue, surface]
+ }
+
+ return [
+ NSColor(calibratedRed: 232 / 255, green: 246 / 255, blue: 1, alpha: 1),
+ NSColor.white
+ ]
+ }
+
+ private func applySpaceLaneGradientFill(to layer: CALayer, colors: [NSColor]) {
+ if let gradientLayer = layer as? CAGradientLayer {
+ gradientLayer.colors = colors.map(\.cgColor)
+ gradientLayer.locations = [0, 1]
+ gradientLayer.startPoint = CGPoint(x: 0.5, y: 1.0)
+ gradientLayer.endPoint = CGPoint(x: 0.5, y: 0.0)
+ }
+ layer.backgroundColor = NSColor.clear.cgColor
+ }
+
+ private func applySpaceLaneSolidFill(to layer: CALayer, color: NSColor) {
+ if let gradientLayer = layer as? CAGradientLayer {
+ gradientLayer.colors = nil
+ gradientLayer.locations = nil
+ }
+ layer.backgroundColor = color.cgColor
}
private func makeFullscreenMarkerLayer(for space: QuickSwitchSpaceViewModel) -> CALayer? {
@@ -3675,11 +4022,18 @@
}
private func fullscreenMarkerFrame(in segmentBounds: CGRect) -> CGRect {
- CGRect(
+ let topReserved: CGFloat = 32
+ let bottomReserved: CGFloat = 16
+ let availableHeight = max(20, segmentBounds.height - topReserved - bottomReserved)
+ let markerHeight = min(30, max(20, availableHeight))
+ let markerCenterY = bottomReserved + availableHeight * 0.5
+ let markerY = max(8, min(segmentBounds.height - topReserved - markerHeight, markerCenterY - markerHeight * 0.5))
+
+ return CGRect(
x: 10,
- y: 30,
+ y: markerY,
width: max(1, segmentBounds.width - 20),
- height: max(1, segmentBounds.height - 58)
+ height: markerHeight
)
}
@@ -4049,6 +4403,10 @@
dx: (group?.containerLayer.frame.minX ?? 0) + spaceLaneContentLayer.frame.minX,
dy: group?.containerLayer.frame.minY ?? 0
)
+ let fullscreenMarkerFrame = segment.fullscreenMarkerLayer?.frame
+ let fullscreenMarkerCenterRatio = fullscreenMarkerFrame.map {
+ Double($0.midY / max(1, segment.containerLayer.bounds.height))
+ }
return [
"displayUUID": display.displayUUID,
"displayLabel": display.displayLabel,
@@ -4058,11 +4416,14 @@
"visualStates": visualStates(for: space),
"fullscreenMarkerVisible": segment.fullscreenMarkerLayer != nil,
"fullscreenMarkerKind": segment.fullscreenMarkerLayer == nil ? "none" : "cornerBracket",
- "fullscreenMarkerFrame": segment.fullscreenMarkerLayer.map { dictionary(from: $0.frame) } ?? NSNull(),
+ "fullscreenMarkerFrame": fullscreenMarkerFrame.map { dictionary(from: $0) } ?? NSNull(),
+ "fullscreenMarkerCenterRatio": fullscreenMarkerCenterRatio ?? NSNull(),
"windowBlockCount": segment.windowBlocks.count,
"windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) },
"windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) },
+ "backgroundKind": spaceLaneFillKind(for: segment.containerLayer),
"backgroundColor": colorDictionary(from: segment.containerLayer.backgroundColor),
+ "gradientColors": gradientColorDictionaries(from: segment.containerLayer),
"borderColor": colorDictionary(from: segment.containerLayer.borderColor),
"borderWidth": Double(segment.containerLayer.borderWidth),
"shadowOpacity": Double(segment.containerLayer.shadowOpacity),
@@ -4105,6 +4466,9 @@
}
if space.id == activeSpaceFocusID {
states.append("focused")
+ }
+ if space.id == currentViewModel?.lockedSpaceID {
+ states.append("locked")
}
if space.type == .fullscreen {
states.append("fullscreen")
@@ -4153,4 +4517,23 @@
"alpha": Double(nsColor.alphaComponent)
]
}
+
+ private func gradientColorDictionaries(from layer: CALayer) -> [[String: Double]] {
+ guard let gradientLayer = layer as? CAGradientLayer else { return [] }
+ return (gradientLayer.colors ?? []).map { color in
+ let cgColor = color as! CGColor
+ return colorDictionary(from: cgColor)
+ }
+ }
+
+ private func spaceLaneFillKind(for layer: CALayer) -> String {
+ if !gradientColorDictionaries(from: layer).isEmpty {
+ return "gradient"
+ }
+ let background = colorDictionary(from: layer.backgroundColor)
+ if background["alpha", default: 0] <= 0.01 {
+ return "clear"
+ }
+ return "solid"
+ }
}
--
Gitblit v1.9.3