From 9c898b0ca61c090e5e327f750f6c73e28475e10b Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 12 Jun 2026 19:30:19 +0800
Subject: [PATCH] Fix space filter visual and close verification
---
C1.source/Sources/Aligner/QuickSwitchRootView.swift | 631 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 622 insertions(+), 9 deletions(-)
diff --git a/C1.source/Sources/Aligner/QuickSwitchRootView.swift b/C1.source/Sources/Aligner/QuickSwitchRootView.swift
index 4f8f371..d3f89ae 100644
--- a/C1.source/Sources/Aligner/QuickSwitchRootView.swift
+++ b/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,15 +52,19 @@
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?
private var focusedSpaceID: UInt64?
private var debugHoveredAppGroupIndex: Int?
private var waterfallColumns: [WaterfallColumnLayers] = []
+ private var waterfallViewMode: QuickSwitchWaterfallViewMode = .verticalColumns
private var waterfallContentWidth: CGFloat = 0
private var waterfallScrollOffset: CGFloat = 0
private var waterfallColumnScrollOffsets: [Int: CGFloat] = [:]
+ private var horizontalMasonryContentHeight: CGFloat = 0
+ private var horizontalMasonryScrollOffset: CGFloat = 0
private var currentSelection: QuickSwitchSelection?
private var columnSelectionHistory: [Int: Int] = [:]
private var keyboardCommandsApplied: [String] = []
@@ -88,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)?
@@ -316,6 +324,16 @@
static let horizontalRevealPadding: CGFloat = 0
}
+ private enum HorizontalMasonryMetrics {
+ static let horizontalPadding: CGFloat = 12
+ static let verticalPadding: CGFloat = 0
+ static let horizontalGap: CGFloat = 16
+ static let verticalGap: CGFloat = WaterfallMetrics.cardGap
+ static let cardMinWidth: CGFloat = 220
+ static let cardMaxWidth: CGFloat = 320
+ static let cardHeight: CGFloat = WaterfallMetrics.cardHeight
+ }
+
private enum WaterfallHorizontalIntent {
case reveal
case alignWithAppShelf(animated: Bool)
@@ -431,6 +449,15 @@
}
if shouldScrollWaterfall(for: event) {
+ if isHorizontalMasonryMode {
+ let currentOffset = horizontalMasonryScrollOffset
+ horizontalMasonryScrollOffset = clampedHorizontalMasonryOffset(
+ currentOffset - event.scrollingDeltaY
+ )
+ needsLayout = true
+ return
+ }
+
if abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY), waterfallAlignmentScrollable {
waterfallScrollOffset = clampedWaterfallAlignmentOffset(waterfallScrollOffset - event.scrollingDeltaX)
needsLayout = true
@@ -457,6 +484,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
@@ -467,6 +505,7 @@
override func mouseExited(with event: NSEvent) {
guard debugHoveredAppGroupIndex == nil else { return }
+ isSettingsButtonHovered = false
hoveredCloseTarget = nil
setHoverTarget(.none)
}
@@ -482,6 +521,11 @@
}
if handleCloseConfirmationMouseDown(at: event.locationInWindow) {
+ return
+ }
+
+ if settingsButtonContains(windowLocation: event.locationInWindow) {
+ clickSettingsButton(source: "mouse")
return
}
@@ -595,6 +639,17 @@
closeConfirmationLayers.containerLayer.isHidden = true
closeConfirmationArrowEdge = nil
}
+ needsLayout = true
+ }
+
+ func setWaterfallViewMode(_ mode: QuickSwitchWaterfallViewMode) {
+ guard waterfallViewMode != mode else { return }
+
+ waterfallViewMode = mode
+ waterfallScrollOffset = 0
+ waterfallColumnScrollOffsets = [:]
+ horizontalMasonryScrollOffset = 0
+ horizontalMasonryContentHeight = 0
needsLayout = true
}
@@ -715,6 +770,10 @@
lastSpaceFocusedAppGroupIndex = nil
lastSpaceLaneClickSpaceID = nil
lastAppShelfScrollSyncedAppGroupIndex = nil
+ waterfallScrollOffset = 0
+ waterfallColumnScrollOffsets = [:]
+ horizontalMasonryScrollOffset = 0
+ horizontalMasonryContentHeight = 0
focusedSpaceID = nil
hoveredCloseTarget = nil
pendingCloseTarget = nil
@@ -1021,6 +1080,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,
@@ -1052,10 +1114,15 @@
"appShelfMinIconSize": Double(AppShelfMetrics.minIconSize),
"appShelfItems": appShelfItemReports(),
"waterfallFrame": dictionary(from: waterfallLayer.frame),
+ "waterfallViewMode": waterfallViewMode.rawValue,
"waterfallContentWidth": Double(waterfallContentWidth),
"waterfallVisibleWidth": Double(waterfallLayer.bounds.width),
"waterfallScrollOffset": Double(waterfallScrollOffset),
"waterfallMaxScrollOffset": Double(waterfallMaxScrollOffset),
+ "horizontalMasonryContentHeight": Double(horizontalMasonryContentHeight),
+ "horizontalMasonryScrollOffset": Double(horizontalMasonryScrollOffset),
+ "horizontalMasonryMaxScrollOffset": Double(horizontalMasonryMaxScrollOffset),
+ "horizontalMasonryScrollable": horizontalMasonryMaxScrollOffset > 0,
"waterfallAlignmentMinScrollOffset": Double(waterfallAlignmentScrollRange.lowerBound),
"waterfallAlignmentMaxScrollOffset": Double(waterfallAlignmentScrollRange.upperBound),
"waterfallScrollable": waterfallMaxScrollOffset > 0,
@@ -1167,8 +1234,13 @@
recordKeyboardCommand("enter")
commitCurrentSelection()
case "tab":
- tabIgnoredCount += 1
- recordKeyboardCommand("tabIgnored")
+ if isHorizontalMasonryMode {
+ recordKeyboardCommand("tab")
+ moveSelection(.right)
+ } else {
+ tabIgnoredCount += 1
+ recordKeyboardCommand("tabIgnored")
+ }
default:
recordKeyboardCommand("unknown:\(command)")
}
@@ -1253,6 +1325,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)")
@@ -1574,6 +1649,11 @@
}
private func moveSelection(_ direction: QuickSwitchKeyboardDirection) {
+ if isHorizontalMasonryMode {
+ moveHorizontalMasonrySelection(direction)
+ return
+ }
+
guard let currentViewModel,
let snapshot = snapshotForFocus(from: currentViewModel),
let current = currentSelection ?? currentViewModel.initialSelection,
@@ -1603,6 +1683,113 @@
"windowID": next.windowID
])
needsLayout = true
+ }
+
+ private func moveHorizontalMasonrySelection(_ direction: QuickSwitchKeyboardDirection) {
+ guard let current = currentSelection ?? currentViewModel?.initialSelection,
+ let next = nextHorizontalMasonrySelection(from: current, direction: direction)
+ else {
+ DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection.blocked", [
+ "direction": String(describing: direction),
+ "hasSelection": (currentSelection ?? currentViewModel?.initialSelection) != nil
+ ])
+ return
+ }
+
+ columnSelectionHistory[current.appGroupIndex] = current.windowIndex
+ currentSelection = next
+ columnSelectionHistory[next.appGroupIndex] = next.windowIndex
+ ensureCurrentSelectionVisible(horizontalIntent: .alignWithAppShelf(animated: true))
+ DevelopmentDiagnostics.log("quickSwitch.view.moveHorizontalMasonrySelection", [
+ "direction": String(describing: direction),
+ "appGroupIndex": next.appGroupIndex,
+ "windowIndex": next.windowIndex,
+ "windowID": next.windowID
+ ])
+ needsLayout = true
+ }
+
+ private func nextHorizontalMasonrySelection(
+ from current: QuickSwitchSelection,
+ direction: QuickSwitchKeyboardDirection
+ ) -> QuickSwitchSelection? {
+ layoutSubtreeIfNeeded()
+
+ let cards = horizontalMasonryCardsInPlacementOrder()
+ guard let currentIndex = cards.firstIndex(where: { $0.item.window.id == current.windowID }) else {
+ return cards.first.map(selectionForCard)
+ }
+
+ switch direction {
+ case .right:
+ return selectionForCard(cards[(currentIndex + 1) % cards.count])
+ case .left:
+ return selectionForCard(cards[(currentIndex - 1 + cards.count) % cards.count])
+ case .up:
+ return nearestHorizontalMasonrySelection(from: cards[currentIndex], direction: .up)
+ ?? selectionForCard(cards.first ?? cards[currentIndex])
+ case .down:
+ return nearestHorizontalMasonrySelection(from: cards[currentIndex], direction: .down)
+ ?? selectionForCard(cards.last ?? cards[currentIndex])
+ }
+ }
+
+ private func nearestHorizontalMasonrySelection(
+ from currentCard: WaterfallCardLayers,
+ direction: QuickSwitchKeyboardDirection
+ ) -> QuickSwitchSelection? {
+ let currentFrame = currentCard.containerLayer.frame
+ let candidates = horizontalMasonryCardsInPlacementOrder().filter { card in
+ guard card.item.window.id != currentCard.item.window.id else { return false }
+ switch direction {
+ case .up:
+ return card.containerLayer.frame.minY >= currentFrame.maxY - 0.5
+ case .down:
+ return card.containerLayer.frame.maxY <= currentFrame.minY + 0.5
+ case .left, .right:
+ return false
+ }
+ }
+
+ let best = candidates.min { lhs, rhs in
+ let lhsFrame = lhs.containerLayer.frame
+ let rhsFrame = rhs.containerLayer.frame
+ let lhsPrimaryDistance = direction == .up
+ ? lhsFrame.minY - currentFrame.maxY
+ : currentFrame.minY - lhsFrame.maxY
+ let rhsPrimaryDistance = direction == .up
+ ? rhsFrame.minY - currentFrame.maxY
+ : currentFrame.minY - rhsFrame.maxY
+ if abs(lhsPrimaryDistance - rhsPrimaryDistance) > 0.5 {
+ return lhsPrimaryDistance < rhsPrimaryDistance
+ }
+
+ let lhsHorizontalDistance = abs(lhsFrame.midX - currentFrame.midX)
+ let rhsHorizontalDistance = abs(rhsFrame.midX - currentFrame.midX)
+ if abs(lhsHorizontalDistance - rhsHorizontalDistance) > 0.5 {
+ return lhsHorizontalDistance < rhsHorizontalDistance
+ }
+
+ return lhs.item.globalIndex < rhs.item.globalIndex
+ }
+
+ return best.map(selectionForCard)
+ }
+
+ private func horizontalMasonryCardsInPlacementOrder() -> [WaterfallCardLayers] {
+ waterfallColumns
+ .sorted { $0.column.appGroupIndex < $1.column.appGroupIndex }
+ .flatMap { column in
+ column.cards.sorted { $0.item.windowIndex < $1.item.windowIndex }
+ }
+ }
+
+ private func selectionForCard(_ card: WaterfallCardLayers) -> QuickSwitchSelection {
+ QuickSwitchSelection(
+ appGroupIndex: card.item.appGroupIndex,
+ windowIndex: card.item.windowIndex,
+ windowID: card.item.window.id
+ )
}
private func commitCurrentSelection() {
@@ -1662,6 +1849,16 @@
_ selection: QuickSwitchSelection,
horizontalIntent: WaterfallHorizontalIntent = .reveal
) {
+ if isHorizontalMasonryMode {
+ switch horizontalIntent {
+ case .reveal:
+ ensureHorizontalMasonrySelectionVisible(selection, animated: false)
+ case .alignWithAppShelf(let animated):
+ ensureHorizontalMasonrySelectionVisible(selection, animated: animated)
+ }
+ return
+ }
+
guard
let column = waterfallColumns.first(where: { $0.column.appGroupIndex == selection.appGroupIndex })
else {
@@ -1683,6 +1880,11 @@
}
private func ensureWaterfallCardVisibleVertically(_ selection: QuickSwitchSelection) {
+ guard !isHorizontalMasonryMode else {
+ ensureHorizontalMasonrySelectionVisible(selection, animated: false)
+ return
+ }
+
let visibleHeight = max(
1,
waterfallLayer.bounds.height
@@ -1710,6 +1912,11 @@
}
private func alignWaterfallColumnWithAppShelfIcon(appGroupIndex: Int, animated: Bool) {
+ if isHorizontalMasonryMode {
+ ensureHorizontalMasonryAppVisible(appGroupIndex, animated: animated)
+ return
+ }
+
guard
let item = appShelfItems.first(where: { $0.item.appGroupIndex == appGroupIndex }),
let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex })
@@ -1736,6 +1943,11 @@
}
private func ensureWaterfallColumnVisible(_ appGroupIndex: Int) {
+ if isHorizontalMasonryMode {
+ ensureHorizontalMasonryAppVisible(appGroupIndex, animated: false)
+ return
+ }
+
guard let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex }) else {
return
}
@@ -1744,6 +1956,11 @@
}
private func ensureWaterfallColumnVisible(_ column: WaterfallColumnLayers) {
+ if isHorizontalMasonryMode {
+ ensureHorizontalMasonryAppVisible(column.column.appGroupIndex, animated: false)
+ return
+ }
+
waterfallScrollOffset = clampedWaterfallOffset(
scrollOffset(
makingVisible: column.containerLayer.frame,
@@ -1756,6 +1973,10 @@
}
private var waterfallAlignmentScrollRange: ClosedRange<CGFloat> {
+ if isHorizontalMasonryMode {
+ return 0...0
+ }
+
if waterfallShouldKeepFilteredColumnsCentered {
return 0...0
}
@@ -1822,6 +2043,80 @@
animation.timingFunction = CAMediaTimingFunction(name: WaterfallAlignmentMetrics.animationTimingName)
animation.isRemovedOnCompletion = true
waterfallContentLayer.add(animation, forKey: "waterfallColumnAlignment")
+ }
+
+ private func ensureHorizontalMasonrySelectionVisible(
+ _ selection: QuickSwitchSelection,
+ animated: Bool
+ ) {
+ guard let card = waterfallCard(
+ appGroupIndex: selection.appGroupIndex,
+ windowIndex: selection.windowIndex
+ ) else {
+ return
+ }
+
+ let visibleFrame = card.containerLayer.frame.offsetBy(
+ dx: 0,
+ dy: waterfallContentLayer.frame.minY
+ )
+ let visibleHeight = waterfallLayer.bounds.height
+ let padding = WaterfallMetrics.revealPadding
+ var nextOffset = horizontalMasonryScrollOffset
+
+ if visibleFrame.maxY > visibleHeight - padding {
+ nextOffset -= visibleFrame.maxY - (visibleHeight - padding)
+ } else if visibleFrame.minY < padding {
+ nextOffset += padding - visibleFrame.minY
+ }
+
+ setHorizontalMasonryScrollOffset(nextOffset, animated: animated)
+ }
+
+ private func ensureHorizontalMasonryAppVisible(_ appGroupIndex: Int, animated: Bool) {
+ guard let firstCard = waterfallColumns
+ .first(where: { $0.column.appGroupIndex == appGroupIndex })?
+ .cards
+ .sorted(by: { $0.item.windowIndex < $1.item.windowIndex })
+ .first
+ else {
+ return
+ }
+
+ let cardTopDistance = horizontalMasonryContentHeight - firstCard.containerLayer.frame.maxY
+ setHorizontalMasonryScrollOffset(
+ cardTopDistance - WaterfallMetrics.revealPadding,
+ animated: animated
+ )
+ }
+
+ private func setHorizontalMasonryScrollOffset(_ offset: CGFloat, animated: Bool) {
+ let nextOffset = clampedHorizontalMasonryOffset(offset)
+ let previousPositionY = waterfallContentLayer.presentation()?.position.y
+ ?? waterfallContentLayer.position.y
+
+ horizontalMasonryScrollOffset = nextOffset
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+ waterfallContentLayer.frame = horizontalMasonryContentFrame()
+ CATransaction.commit()
+
+ let nextPositionY = waterfallContentLayer.position.y
+ guard animated,
+ !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion,
+ abs(previousPositionY - nextPositionY) > 0.5
+ else {
+ waterfallContentLayer.removeAnimation(forKey: "horizontalMasonryAlignment")
+ return
+ }
+
+ let animation = CABasicAnimation(keyPath: "position.y")
+ animation.fromValue = previousPositionY
+ animation.toValue = nextPositionY
+ animation.duration = WaterfallAlignmentMetrics.animationDuration
+ animation.timingFunction = CAMediaTimingFunction(name: WaterfallAlignmentMetrics.animationTimingName)
+ animation.isRemovedOnCompletion = true
+ waterfallContentLayer.add(animation, forKey: "horizontalMasonryAlignment")
}
private func scrollOffset(
@@ -1975,6 +2270,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() {
@@ -2312,8 +2627,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 {
@@ -2501,10 +2848,10 @@
let isActive = isHovered || isFocused || isLocked
let isAssociated = (isAppAssociated || isWindowAssociated) && !isActive
- segment.containerLayer.borderWidth = isLocked
- ? 6
- : (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1
- segment.containerLayer.borderColor = (isActive
+ segment.containerLayer.borderWidth = (hasWindows || isCurrent || isActive || isAssociated) ? 1.5 : 1
+ segment.containerLayer.borderColor = (isLocked
+ ? NSColor.white.withAlphaComponent(0.86)
+ : isActive
? spaceLaneOccupiedColor.withAlphaComponent(isLocked ? 0.92 : 0.62)
: isAssociated
? spaceLaneOccupiedColor.withAlphaComponent(0.50)
@@ -2514,7 +2861,12 @@
? NSColor.labelColor.withAlphaComponent(0.34)
: NSColor.separatorColor.withAlphaComponent(0.34)
).cgColor
- if !hasWindows {
+ if isLocked {
+ applySpaceLaneSolidFill(
+ to: segment.containerLayer,
+ color: spaceLaneLockedFillColor
+ )
+ } else if !hasWindows {
applySpaceLaneSolidFill(to: segment.containerLayer, color: .clear)
} else if isActive {
applySpaceLaneSolidFill(
@@ -2549,7 +2901,7 @@
segment.containerLayer.transform = (isActive || isAssociated)
? CATransform3DMakeScale(isActive ? 1.03 : 1.015, isActive ? 1.03 : 1.015, 1)
: CATransform3DIdentity
- segment.containerLayer.shadowPath = hasWindows
+ segment.containerLayer.shadowPath = (hasWindows || isLocked)
? CGPath(
roundedRect: segment.containerLayer.bounds,
cornerWidth: segment.containerLayer.cornerRadius,
@@ -2557,6 +2909,34 @@
transform: nil
)
: nil
+
+ applySpaceLaneContentVisualState(segment, isLocked: isLocked)
+ }
+
+ private func applySpaceLaneContentVisualState(_ segment: SpaceLaneSegmentLayers, isLocked: Bool) {
+ let primaryTextColor: NSColor = isLocked ? .white : .labelColor
+ let secondaryTextColor: NSColor = isLocked
+ ? .white
+ : .secondaryLabelColor
+
+ segment.labelLayer.foregroundColor = primaryTextColor.cgColor
+ segment.countLayer.foregroundColor = (isLocked ? NSColor.white : .secondaryLabelColor).cgColor
+ segment.appLayer.foregroundColor = (segment.space.type == .fullscreen
+ ? primaryTextColor
+ : secondaryTextColor
+ ).cgColor
+
+ for block in segment.windowBlocks {
+ block.backgroundColor = (isLocked
+ ? NSColor.white
+ : spaceLaneOccupiedColor.withAlphaComponent(segment.space.isCurrent ? 0.72 : 0.58)
+ ).cgColor
+ }
+
+ (segment.fullscreenMarkerLayer as? CAShapeLayer)?.strokeColor = (isLocked
+ ? NSColor.white
+ : NSColor.secondaryLabelColor.withAlphaComponent(segment.space.isCurrent ? 0.42 : 0.28)
+ ).cgColor
}
private func shouldScrollSpaceLane(for event: NSEvent) -> Bool {
@@ -2627,6 +3007,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? {
@@ -2942,6 +3342,10 @@
return "none"
}
+ guard !isHorizontalMasonryMode else {
+ return "visible"
+ }
+
let visibleMinX = waterfallScrollOffset
let visibleMaxX = waterfallScrollOffset + waterfallLayer.bounds.width
if column.containerLayer.frame.maxX < visibleMinX + WaterfallMetrics.gap {
@@ -3024,9 +3428,16 @@
}
private func layoutWaterfallColumns() {
+ if isHorizontalMasonryMode {
+ layoutHorizontalMasonryWaterfall()
+ return
+ }
+
guard !waterfallColumns.isEmpty else {
waterfallContentWidth = waterfallLayer.bounds.width
waterfallScrollOffset = 0
+ horizontalMasonryContentHeight = waterfallLayer.bounds.height
+ horizontalMasonryScrollOffset = 0
waterfallContentLayer.frame = waterfallLayer.bounds
return
}
@@ -3057,6 +3468,154 @@
}
}
+ private func layoutHorizontalMasonryWaterfall() {
+ guard !waterfallColumns.isEmpty else {
+ waterfallContentWidth = waterfallLayer.bounds.width
+ waterfallScrollOffset = 0
+ horizontalMasonryContentHeight = waterfallLayer.bounds.height
+ horizontalMasonryScrollOffset = 0
+ waterfallContentLayer.frame = waterfallLayer.bounds
+ return
+ }
+
+ let layout = horizontalMasonryLayout()
+ waterfallContentWidth = waterfallLayer.bounds.width
+ waterfallScrollOffset = 0
+ horizontalMasonryContentHeight = layout.contentHeight
+ horizontalMasonryScrollOffset = clampedHorizontalMasonryOffset(horizontalMasonryScrollOffset)
+ waterfallContentLayer.frame = horizontalMasonryContentFrame()
+
+ for column in waterfallColumns {
+ column.containerLayer.frame = CGRect(
+ x: 0,
+ y: 0,
+ width: waterfallContentWidth,
+ height: horizontalMasonryContentHeight
+ )
+ column.containerLayer.masksToBounds = false
+ column.containerLayer.backgroundColor = NSColor.clear.cgColor
+ column.containerLayer.borderColor = NSColor.clear.cgColor
+ column.containerLayer.borderWidth = 0
+ column.containerLayer.shadowOpacity = 0
+ column.containerLayer.shadowRadius = 0
+ column.containerLayer.shadowOffset = .zero
+ column.headerLayer.isHidden = true
+ column.appNameLayer.isHidden = true
+ column.countLayer.isHidden = true
+
+ for card in column.cards {
+ if let frame = layout.cardFramesByWindowID[card.item.window.id] {
+ card.containerLayer.frame = frame
+ } else {
+ card.containerLayer.frame = .zero
+ }
+ layoutWaterfallCard(card)
+ }
+ }
+ }
+
+ private struct HorizontalMasonryLayout {
+ let contentHeight: CGFloat
+ let cardFramesByWindowID: [UInt32: CGRect]
+ }
+
+ private func horizontalMasonryLayout() -> HorizontalMasonryLayout {
+ let cards = horizontalMasonryCardsInPlacementOrder()
+ guard !cards.isEmpty else {
+ return HorizontalMasonryLayout(
+ contentHeight: waterfallLayer.bounds.height,
+ cardFramesByWindowID: [:]
+ )
+ }
+
+ let viewportWidth = max(1, waterfallLayer.bounds.width)
+ let availableWidth = max(
+ HorizontalMasonryMetrics.cardMinWidth,
+ viewportWidth - HorizontalMasonryMetrics.horizontalPadding * 2
+ )
+ let maximumLaneCount = max(
+ 1,
+ Int(
+ floor(
+ (availableWidth + HorizontalMasonryMetrics.horizontalGap)
+ / (HorizontalMasonryMetrics.cardMinWidth + HorizontalMasonryMetrics.horizontalGap)
+ )
+ )
+ )
+ let laneCount = min(maximumLaneCount, max(1, cards.count))
+ let fittingWidth = (
+ availableWidth - CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.horizontalGap
+ ) / CGFloat(laneCount)
+ let cardWidth = floor(
+ min(
+ HorizontalMasonryMetrics.cardMaxWidth,
+ max(HorizontalMasonryMetrics.cardMinWidth, fittingWidth)
+ )
+ )
+ let usedWidth = CGFloat(laneCount) * cardWidth
+ + CGFloat(max(0, laneCount - 1)) * HorizontalMasonryMetrics.horizontalGap
+ let startX = max(
+ HorizontalMasonryMetrics.horizontalPadding,
+ (viewportWidth - usedWidth) / 2
+ )
+
+ var laneHeights = Array(repeating: CGFloat(0), count: laneCount)
+ var plannedFrames: [(windowID: UInt32, x: CGFloat, top: CGFloat, width: CGFloat, height: CGFloat)] = []
+
+ for card in cards {
+ let laneIndex = laneHeights.enumerated().min { lhs, rhs in
+ if abs(lhs.element - rhs.element) > 0.5 {
+ return lhs.element < rhs.element
+ }
+ return lhs.offset < rhs.offset
+ }?.offset ?? 0
+ let cardHeight = HorizontalMasonryMetrics.cardHeight
+ let x = startX + CGFloat(laneIndex) * (cardWidth + HorizontalMasonryMetrics.horizontalGap)
+ let top = HorizontalMasonryMetrics.verticalPadding + laneHeights[laneIndex]
+ plannedFrames.append((
+ windowID: card.item.window.id,
+ x: x,
+ top: top,
+ width: cardWidth,
+ height: cardHeight
+ ))
+ laneHeights[laneIndex] += cardHeight + HorizontalMasonryMetrics.verticalGap
+ }
+
+ let tallestLane = laneHeights.max().map {
+ max(0, $0 - HorizontalMasonryMetrics.verticalGap)
+ } ?? 0
+ let contentHeight = max(
+ waterfallLayer.bounds.height,
+ HorizontalMasonryMetrics.verticalPadding * 2 + tallestLane
+ )
+ let frames = Dictionary(
+ uniqueKeysWithValues: plannedFrames.map { planned in
+ (
+ planned.windowID,
+ CGRect(
+ x: planned.x,
+ y: contentHeight - planned.top - planned.height,
+ width: planned.width,
+ height: planned.height
+ )
+ )
+ }
+ )
+
+ return HorizontalMasonryLayout(contentHeight: contentHeight, cardFramesByWindowID: frames)
+ }
+
+ private func horizontalMasonryContentFrame() -> CGRect {
+ let contentHeight = max(waterfallLayer.bounds.height, horizontalMasonryContentHeight)
+ return CGRect(
+ x: 0,
+ y: waterfallLayer.bounds.height - contentHeight + horizontalMasonryScrollOffset,
+ width: waterfallLayer.bounds.width,
+ height: contentHeight
+ )
+ }
+
private func waterfallColumnStartX(columnsWidth: CGFloat) -> CGFloat {
guard currentViewModel?.lockedSpaceID != nil else {
return WaterfallMetrics.contentHorizontalPadding
@@ -3067,6 +3626,16 @@
}
private func layoutWaterfallColumn(_ column: WaterfallColumnLayers) {
+ column.containerLayer.cornerRadius = 14
+ column.containerLayer.masksToBounds = true
+ column.containerLayer.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.56).cgColor
+ column.containerLayer.shadowOpacity = 0
+ column.containerLayer.shadowRadius = 0
+ column.containerLayer.shadowOffset = .zero
+ column.headerLayer.isHidden = false
+ column.appNameLayer.isHidden = false
+ column.countLayer.isHidden = false
+
let bounds = column.containerLayer.bounds
let isSpaceFocused = spaceFocusWindowCount(for: column.column.appGroupIndex) > 0
let isHovered = column.column.appGroupIndex == effectiveHoveredAppGroupIndex
@@ -3787,15 +4356,30 @@
)
}
+ private var isHorizontalMasonryMode: Bool {
+ waterfallViewMode == .horizontalMasonry
+ }
+
private var waterfallMaxScrollOffset: CGFloat {
- max(0, waterfallContentWidth - waterfallLayer.bounds.width)
+ guard !isHorizontalMasonryMode else { return 0 }
+ return max(0, waterfallContentWidth - waterfallLayer.bounds.width)
}
private func clampedWaterfallOffset(_ offset: CGFloat) -> CGFloat {
min(max(0, offset), waterfallMaxScrollOffset)
}
+ private var horizontalMasonryMaxScrollOffset: CGFloat {
+ max(0, horizontalMasonryContentHeight - waterfallLayer.bounds.height)
+ }
+
+ private func clampedHorizontalMasonryOffset(_ offset: CGFloat) -> CGFloat {
+ min(max(0, offset), horizontalMasonryMaxScrollOffset)
+ }
+
private func waterfallMaxVerticalScrollOffset(for appGroupIndex: Int) -> CGFloat {
+ guard !isHorizontalMasonryMode else { return 0 }
+
guard
let column = waterfallColumns.first(where: { $0.column.appGroupIndex == appGroupIndex })
else {
@@ -3844,6 +4428,14 @@
return NSColor(calibratedRed: 0, green: 122 / 255, blue: 1, alpha: 1)
}
+ private var spaceLaneLockedFillColor: NSColor {
+ if effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
+ return NSColor(calibratedRed: 58 / 255, green: 148 / 255, blue: 1, alpha: 0.86)
+ }
+
+ return NSColor(calibratedRed: 90 / 255, green: 171 / 255, blue: 1, alpha: 0.82)
+ }
+
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)
@@ -3885,6 +4477,21 @@
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(
@@ -4493,6 +5100,12 @@
"windowBlockCount": segment.windowBlocks.count,
"windowBlockColors": segment.windowBlocks.map { colorDictionary(from: $0.backgroundColor) },
"windowBlockFrames": segment.windowBlocks.map { dictionary(from: $0.frame) },
+ "labelColor": colorDictionary(from: segment.labelLayer.foregroundColor),
+ "countColor": colorDictionary(from: segment.countLayer.foregroundColor),
+ "appColor": colorDictionary(from: segment.appLayer.foregroundColor),
+ "fullscreenMarkerStrokeColor": colorDictionary(
+ from: (segment.fullscreenMarkerLayer as? CAShapeLayer)?.strokeColor
+ ),
"backgroundKind": spaceLaneFillKind(for: segment.containerLayer),
"backgroundColor": colorDictionary(from: segment.containerLayer.backgroundColor),
"gradientColors": gradientColorDictionaries(from: segment.containerLayer),
--
Gitblit v1.9.3