diff --git a/Sources/DefiCore/Overview.swift b/Sources/DefiCore/Overview.swift index 6bfb70c..1abb4ed 100644 --- a/Sources/DefiCore/Overview.swift +++ b/Sources/DefiCore/Overview.swift @@ -76,19 +76,113 @@ public func interpolateOverviewViewport( ) } +public func interpolateOverviewProjection( + from: OverviewProjection, + to: OverviewProjection, + progress: Double, + foregroundWindowID: WindowID? = nil +) -> OverviewProjection { + guard from.monitorID == to.monitorID else { return to } + let progress = min(max(progress, 0), 1) + let sourceWorkspaces = Dictionary( + uniqueKeysWithValues: from.workspaces.map { ($0.workspaceID, $0) } + ) + let sourceWindows = Dictionary( + uniqueKeysWithValues: from.workspaces.flatMap(\.windows).map { ($0.windowID, $0) } + ) + let sourceWorkspaceID = from.workspaces.first(where: { workspace in + workspace.windows.contains(where: { $0.windowID == foregroundWindowID }) + })?.workspaceID + let targetWorkspaceID = to.workspaces.first(where: { workspace in + workspace.windows.contains(where: { $0.windowID == foregroundWindowID }) + })?.workspaceID + let overlayWindowID = sourceWorkspaceID != targetWorkspaceID + ? foregroundWindowID + : nil + return OverviewProjection( + monitorID: to.monitorID, + workspaces: to.workspaces.map { targetWorkspace in + guard let sourceWorkspace = sourceWorkspaces[targetWorkspace.workspaceID] + else { return targetWorkspace } + var windows = targetWorkspace.windows.map { targetWindow in + guard let sourceWindow = sourceWindows[targetWindow.windowID] + else { return targetWindow } + return OverviewWindowProjection( + windowID: targetWindow.windowID, + frame: interpolateOverviewRect( + from: sourceWindow.frame, + to: targetWindow.frame, + progress: progress + ), + layer: targetWindow.layer, + isNativeFullscreen: targetWindow.isNativeFullscreen, + canDrag: targetWindow.canDrag + ) + } + if let foregroundWindowID, + let index = windows.firstIndex(where: { $0.windowID == foregroundWindowID }) + { + windows.append(windows.remove(at: index)) + } + return OverviewWorkspaceProjection( + workspaceID: targetWorkspace.workspaceID, + frame: interpolateOverviewRect( + from: sourceWorkspace.frame, + to: targetWorkspace.frame, + progress: progress + ), + windows: windows, + hiddenTiledWindowCountBefore: targetWorkspace.hiddenTiledWindowCountBefore, + hiddenTiledWindowCountAfter: targetWorkspace.hiddenTiledWindowCountAfter + ) + }, + overlayWindowID: overlayWindowID + ) +} + +private func interpolateOverviewRect( + from: Rect, + to: Rect, + progress: Double +) -> Rect { + Rect( + x: from.x + (to.x - from.x) * progress, + y: from.y + (to.y - from.y) * progress, + width: from.width + (to.width - from.width) * progress, + height: from.height + (to.height - from.height) * progress + ) +} + public struct OverviewProjection: Equatable, Sendable { public let monitorID: MonitorID public let workspaces: [OverviewWorkspaceProjection] + public let overlayWindowID: WindowID? public init( monitorID: MonitorID, - workspaces: [OverviewWorkspaceProjection] + workspaces: [OverviewWorkspaceProjection], + overlayWindowID: WindowID? = nil ) { self.monitorID = monitorID self.workspaces = workspaces + self.overlayWindowID = overlayWindowID } public func hitTest(_ point: OverviewPoint) -> OverviewHit? { + if let overlayWindowID, + let workspace = workspaces.first(where: { workspace in + workspace.windows.contains(where: { $0.windowID == overlayWindowID }) + }), + let window = workspace.windows.first(where: { + $0.windowID == overlayWindowID && $0.frame.contains(point) + }) + { + return .window( + windowID: window.windowID, + monitorID: monitorID, + workspaceID: workspace.workspaceID + ) + } for workspace in workspaces.reversed() { for window in workspace.windows.reversed() where window.frame.contains(point) && workspace.frame.contains(point) { @@ -207,6 +301,12 @@ public func projectOverview( ) let workspaceHeight = stride - workspaceGap let contentScale = workspaceHeight / monitorFrame.height + let projectionBounds = Rect( + x: bounds.x, + y: bounds.y - stride, + width: bounds.width, + height: bounds.height + stride * 2 + ) let windows = Array(snapshot.windows.values) var projected: [OverviewWorkspaceProjection] = [] @@ -219,7 +319,7 @@ public func projectOverview( width: workspaceWidth, height: workspaceHeight ) - guard workspaceFrame.intersects(bounds) else { continue } + guard workspaceFrame.intersects(projectionBounds) else { continue } var workspace = originalWorkspace workspace.scrollOffset = viewport.horizontalOffsets[workspace.id] diff --git a/Sources/DefiDaemon/DaemonDesktop.swift b/Sources/DefiDaemon/DaemonDesktop.swift index 619a2a9..50b7bcd 100644 --- a/Sources/DefiDaemon/DaemonDesktop.swift +++ b/Sources/DefiDaemon/DaemonDesktop.swift @@ -72,6 +72,8 @@ extension Daemon { var hiddenWindowIDs = Set() var outOfScopeWindowIDs = Set() let allPhysicalMonitorFrames = latestMonitors.map(\.physicalFrame) + let overviewParksWindows = overviewController?.isOpen == true + && overviewController?.usesWorkspaceParking == true let liveMonitorIDs = Set(state.monitors.map(\.id)) layoutPlansByMonitor = layoutPlansByMonitor.filter { liveMonitorIDs.contains($0.key) @@ -118,7 +120,7 @@ extension Daemon { excludingWindowIDs: state.nativeFullscreenWindowIDs ) let sizedFrames = layout.frames.map(preserveIntrinsicSize) - if workspace.id == monitor.activeWorkspace { + if workspace.id == monitor.activeWorkspace && !overviewParksWindows { let strip = continuousStripFramesForActiveWorkspace( sizedFrames, viewport: viewport, diff --git a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift index e2a0b67..ac1592b 100644 --- a/Sources/DefiDaemon/DaemonDesktopSynchronization.swift +++ b/Sources/DefiDaemon/DaemonDesktopSynchronization.swift @@ -7,6 +7,18 @@ import DefiModel import DefiRuntime import Foundation +func shouldCloseOverviewAfterNativeFocusChange( + nativeFocusChanged: Bool, + overviewOpenedAt: TimeInterval?, + mouseFocusIntentTimestamp: TimeInterval?, + keyboardFocusIntentTimestamp: TimeInterval? +) -> Bool { + guard nativeFocusChanged else { return false } + guard let overviewOpenedAt else { return true } + return max(mouseFocusIntentTimestamp ?? 0, keyboardFocusIntentTimestamp ?? 0) + > overviewOpenedAt +} + @MainActor extension Daemon { func synchronizeDesktop( @@ -64,7 +76,12 @@ extension Daemon { } } let snapshotCompletedAt = ProcessInfo.processInfo.systemUptime - if snapshot.nativeFocusChanged { + if shouldCloseOverviewAfterNativeFocusChange( + nativeFocusChanged: snapshot.nativeFocusChanged, + overviewOpenedAt: overviewOpenedAt, + mouseFocusIntentTimestamp: snapshot.mouseFocusIntentTimestamp, + keyboardFocusIntentTimestamp: snapshot.keyboardFocusIntentTimestamp + ) { overviewController?.close() } nextPeriodicWindowRefreshAt = boundedSnapshotRefreshDeadline( diff --git a/Sources/DefiDaemon/DaemonOverview.swift b/Sources/DefiDaemon/DaemonOverview.swift index a4847fc..317f831 100644 --- a/Sources/DefiDaemon/DaemonOverview.swift +++ b/Sources/DefiDaemon/DaemonOverview.swift @@ -68,8 +68,22 @@ extension Daemon { self?.activeMonitorID = monitorID }, openStateChanged: { [weak self] isOpen in - self?.hotKeys?.setOverviewModeEnabled(isOpen) - self?.platform.setWindowBordersSuppressed(isOpen) + guard let self else { return } + let parksWindows = overviewController?.usesWorkspaceParking == true + overviewOpenedAt = isOpen && parksWindows + ? ProcessInfo.processInfo.systemUptime + : nil + hotKeys?.setOverviewModeEnabled(isOpen) + platform.setWindowBordersSuppressed(isOpen) + if parksWindows { + applyCurrentLayout( + asynchronousPositions: true, + updateVisibility: true, + positionTimeoutSeconds: 0.05, + stagesVisibleBeforeParking: !isOpen, + source: isOpen ? "overview-park" : "overview-restore" + ) + } } ) } diff --git a/Sources/DefiDaemon/DefiDaemon.swift b/Sources/DefiDaemon/DefiDaemon.swift index 8e6533a..22e6ed6 100644 --- a/Sources/DefiDaemon/DefiDaemon.swift +++ b/Sources/DefiDaemon/DefiDaemon.swift @@ -167,6 +167,7 @@ final class Daemon: NSObject { var placementSaveWorkItem: DispatchWorkItem? var hotKeys: HotKeyManager? var overviewController: OverviewController? + var overviewOpenedAt: TimeInterval? var menuBar: MenuBarController? var lastPublishedWorkspaceState: WorkspaceStateSnapshot? var lastWorkspacePublishState: RuntimeState? diff --git a/Sources/DefiMacOS/HotKeyModels.swift b/Sources/DefiMacOS/HotKeyModels.swift index a4f8149..be0f407 100644 --- a/Sources/DefiMacOS/HotKeyModels.swift +++ b/Sources/DefiMacOS/HotKeyModels.swift @@ -30,6 +30,8 @@ public enum OverviewKeyAction: Equatable, Sendable { case right case up case down + case moveUp + case moveDown case select case cancel } @@ -37,13 +39,20 @@ public enum OverviewKeyAction: Equatable, Sendable { func overviewKeyAction( keyCode: CGKeyCode, modifierBits: UInt64, - isConfiguredBinding: Bool = false + configuredCommand: String? = nil ) -> OverviewKeyAction? { + let commandName = configuredCommand?.split(whereSeparator: \.isWhitespace).first + let navigatesOverview = + modifierBits == 0 + || commandName == "focus-column" + || commandName == "focus-window" return switch keyCode { - case 123 where modifierBits == 0 || isConfiguredBinding: .left - case 124 where modifierBits == 0 || isConfiguredBinding: .right - case 125 where modifierBits == 0 || isConfiguredBinding: .down - case 126 where modifierBits == 0 || isConfiguredBinding: .up + case 125 where commandName == "move-window": .moveDown + case 126 where commandName == "move-window": .moveUp + case 123 where navigatesOverview: .left + case 124 where navigatesOverview: .right + case 125 where navigatesOverview: .down + case 126 where navigatesOverview: .up case 36 where modifierBits == 0: .select case 76 where modifierBits == 0: .select case 53 where modifierBits == 0: .cancel diff --git a/Sources/DefiMacOS/HotKeys.swift b/Sources/DefiMacOS/HotKeys.swift index c497c5c..35afae6 100644 --- a/Sources/DefiMacOS/HotKeys.swift +++ b/Sources/DefiMacOS/HotKeys.swift @@ -374,7 +374,7 @@ private final class HotKeyTapContext: @unchecked Sendable { let action = overviewKeyAction( keyCode: code, modifierBits: key.modifierBits, - isConfiguredBinding: bindings[key] != nil + configuredCommand: bindings[key] ) { lock.lock() diff --git a/Sources/DefiMacOS/OverviewController.swift b/Sources/DefiMacOS/OverviewController.swift index 629c00a..4b4f34a 100644 --- a/Sources/DefiMacOS/OverviewController.swift +++ b/Sources/DefiMacOS/OverviewController.swift @@ -11,6 +11,13 @@ private struct OverviewViewportAnimation { let duration: TimeInterval } +private struct OverviewProjectionAnimation { + let from: OverviewProjection + let to: OverviewProjection + let startedAt: TimeInterval + let duration: TimeInterval +} + private struct RememberedOverviewPreview { let image: NSImage let appID: String @@ -27,6 +34,13 @@ func overviewScrollAxis(for delta: NSPoint) -> OverviewScrollAxis? { return abs(delta.y) >= abs(delta.x) ? .vertical : .horizontal } +func overviewUsesWorkspaceParking( + windowPreviewsEnabled: Bool, + screenCaptureAccessGranted: Bool +) -> Bool { + !windowPreviewsEnabled || !screenCaptureAccessGranted +} + func overviewViewportAfterScroll( _ viewport: OverviewViewport, delta: NSPoint, @@ -72,6 +86,29 @@ func overviewViewportAfterScroll( return viewport } +func overviewViewportTransitionAfterSelectionAlignment( + current: OverviewViewport, + pendingTarget: OverviewViewport?, + animationTarget: OverviewViewport?, + workspaceID: WorkspaceID, + scrollOffset: Double, + sourceWorkspaceID: WorkspaceID?, + sourceMaximumHorizontalOffset: Double?, + movedSelection: Bool +) -> (current: OverviewViewport, target: OverviewViewport?) { + var aligned = pendingTarget ?? animationTarget ?? current + aligned.horizontalOffsets[workspaceID] = scrollOffset + if let sourceWorkspaceID, let sourceMaximumHorizontalOffset { + aligned.horizontalOffsets[sourceWorkspaceID] = min( + max(aligned.horizontalOffsets[sourceWorkspaceID, default: 0], 0), + sourceMaximumHorizontalOffset + ) + } + return movedSelection + ? (current: aligned, target: nil) + : (current: current, target: aligned) +} + @MainActor public final class OverviewController: NSObject { private static let rememberedPreviewByteLimit = 16 * 1_024 * 1_024 @@ -122,10 +159,12 @@ public final class OverviewController: NSObject { private var animationsEnabled = true private var overviewZoom = 0.5 private var viewportAnimations: [MonitorID: OverviewViewportAnimation] = [:] + private var projectionAnimations: [MonitorID: OverviewProjectionAnimation] = [:] private var viewportDisplayLinks: [MonitorID: CADisplayLink] = [:] private var displayLinkMonitorIDs: [ObjectIdentifier: MonitorID] = [:] public private(set) var isOpen = false + public private(set) var usesWorkspaceParking = false public var panelCount: Int { panels.count } public private(set) var previewPermissionState: OverviewPreviewPermissionState = .disabled public private(set) var previewFailureCount = 0 @@ -215,6 +254,10 @@ public final class OverviewController: NSObject { animationsEnabled = animation.enabled overviewZoom = zoom self.windowPreviewsEnabled = windowPreviewsEnabled + usesWorkspaceParking = overviewUsesWorkspaceParking( + windowPreviewsEnabled: windowPreviewsEnabled, + screenCaptureAccessGranted: CGPreflightScreenCaptureAccess() + ) previewTask?.cancel() previewTask = nil previewCache.removeAll(keepingCapacity: true) @@ -245,6 +288,7 @@ public final class OverviewController: NSObject { panels[monitor.id] = OverviewPanel( monitorID: monitor.id, screen: screen, + usesCapturedDesktop: !usesWorkspaceParking, delegate: self ) } @@ -267,6 +311,8 @@ public final class OverviewController: NSObject { windowPreviewsEnabled: Bool? = nil ) { guard isOpen else { return } + let previousSnapshot = self.snapshot + let previousProjections = projections if let windowPreviewsEnabled, self.windowPreviewsEnabled != windowPreviewsEnabled { @@ -291,11 +337,22 @@ public final class OverviewController: NSObject { borderStyle = WindowBorderStyle(config: borders) animationsEnabled = animation.enabled overviewZoom = zoom + var movedSelectionPositions: ( + previous: OverviewTiledPosition, + next: OverviewTiledPosition + )? if drag == nil { let focusedSelection = initialSelection(in: snapshot) if focusedSelection != selection { selection = focusedSelection alignSelectionOnNextUpdate = true + } else if let windowID = selection?.windowID, + let previousPosition = previousSnapshot?.tiledPosition(of: windowID), + let nextPosition = snapshot.tiledPosition(of: windowID), + previousPosition != nextPosition + { + alignSelectionOnNextUpdate = true + movedSelectionPositions = (previousPosition, nextPosition) } } else if let drag, snapshot.windows[drag.windowID]?.appID != drag.appID @@ -329,7 +386,7 @@ public final class OverviewController: NSObject { }), var viewport = viewports[monitor.id] { - cancelViewportAnimation(on: monitor.id) + cancelAnimations(on: monitor.id) viewport.workspaceOffset += Double(previousIndex - activeIndex) viewports[monitor.id] = viewport viewport.workspaceOffset = 0 @@ -339,16 +396,34 @@ public final class OverviewController: NSObject { } if alignSelectionOnNextUpdate, let location = selection?.location, - let workspace = snapshot.monitors.first(where: { + let monitor = snapshot.monitors.first(where: { $0.id == location.monitorID - })?.workspaces.first(where: { $0.id == location.workspaceID }) + }), + let workspace = monitor.workspaces.first(where: { + $0.id == location.workspaceID + }) { - var viewport = viewportTargets[location.monitorID] - ?? viewportAnimations[location.monitorID]?.to - ?? viewports[location.monitorID] - ?? OverviewViewport() - viewport.horizontalOffsets[location.workspaceID] = workspace.scrollOffset - viewportTargets[location.monitorID] = viewport + let movedSelection = movedSelectionPositions?.next.monitorID == location.monitorID + let sourceWorkspaceID = movedSelectionPositions?.previous.monitorID == location.monitorID + ? movedSelectionPositions?.previous.workspaceID + : nil + let transition = overviewViewportTransitionAfterSelectionAlignment( + current: viewports[location.monitorID] ?? OverviewViewport(), + pendingTarget: viewportTargets[location.monitorID], + animationTarget: viewportAnimations[location.monitorID]?.to, + workspaceID: location.workspaceID, + scrollOffset: workspace.scrollOffset, + sourceWorkspaceID: sourceWorkspaceID, + sourceMaximumHorizontalOffset: sourceWorkspaceID.flatMap { + maximumHorizontalOffset(for: $0, on: monitor) + }, + movedSelection: movedSelection + ) + if movedSelection { + cancelAnimations(on: location.monitorID) + } + viewports[location.monitorID] = transition.current + viewportTargets[location.monitorID] = transition.target alignSelectionOnNextUpdate = false } let monitorIDs = Set(snapshot.monitors.map(\.id)) @@ -365,6 +440,12 @@ public final class OverviewController: NSObject { for (monitorID, viewport) in viewportTargets { animateViewport(on: monitorID, to: viewport) } + if let movedSelectionMonitorID = movedSelectionPositions?.next.monitorID { + animateProjection( + on: movedSelectionMonitorID, + from: previousProjections[movedSelectionMonitorID] + ) + } updatePanels() } @@ -385,7 +466,7 @@ public final class OverviewController: NSObject { attemptedPreviewWindowIDs.removeAll(keepingCapacity: true) hasAttemptedDesktopCapture = false previewPendingCount = 0 - stopViewportAnimations() + stopOverviewAnimations() openStateHandler(false) let closingPanels = Array(panels.values) panels.removeAll(keepingCapacity: true) @@ -405,6 +486,8 @@ public final class OverviewController: NSObject { chooseSelection() case .left, .right, .up, .down: navigate(action) + case .moveUp, .moveDown: + moveSelectionVertically(action) } } @@ -464,6 +547,72 @@ public final class OverviewController: NSObject { updatePanels() } + private func moveSelectionVertically(_ action: OverviewKeyAction) { + guard let snapshot, + case .window(let windowID, let monitorID, let workspaceID) = selection, + let window = snapshot.windows[windowID], + window.transientOwnerID == nil, + snapshot.nativeFullscreenWindowIDs.contains(windowID) == false, + let monitor = snapshot.monitors.first(where: { $0.id == monitorID }), + let workspaceIndex = monitor.workspaces.firstIndex(where: { + $0.id == workspaceID + }) + else { return } + let workspace = monitor.workspaces[workspaceIndex] + let delta = action == .moveUp ? -1 : 1 + let target: OverviewDropTarget + if let columnIndex = workspace.columns.firstIndex(where: { + $0.windows.contains(windowID) + }), + let windowIndex = workspace.columns[columnIndex].windows.firstIndex(of: windowID), + workspace.columns[columnIndex].windows.indices.contains(windowIndex + delta) + { + target = .stack( + monitorID: monitorID, + workspaceID: workspaceID, + columnIndex: columnIndex, + windowIndex: delta < 0 ? windowIndex - 1 : windowIndex + 2 + ) + } else { + let targetWorkspaceIndex = workspaceIndex + delta + guard monitor.workspaces.indices.contains(targetWorkspaceIndex) else { return } + let targetWorkspace = monitor.workspaces[targetWorkspaceIndex] + if let columnIndex = workspace.columns.firstIndex(where: { + $0.windows.contains(windowID) + }) { + target = .newColumn( + monitorID: monitorID, + workspaceID: targetWorkspace.id, + columnIndex: min(columnIndex, targetWorkspace.columns.count) + ) + } else { + guard workspace.floatingWindows.contains(windowID), + let monitorFrame = snapshot.monitorFrames[monitorID], + let frame = snapshot.floatingFrames[windowID], + monitorFrame.width > 0, + monitorFrame.height > 0 + else { return } + target = .floating( + monitorID: monitorID, + workspaceID: targetWorkspace.id, + relativeFrame: Rect( + x: (frame.x - monitorFrame.x) / monitorFrame.width, + y: (frame.y - monitorFrame.y) / monitorFrame.height, + width: frame.width / monitorFrame.width, + height: frame.height / monitorFrame.height + ) + ) + } + } + commitOverviewDrop( + windowID: windowID, + appID: window.appID, + sourceMonitorID: monitorID, + sourceWorkspaceID: workspaceID, + target: target + ) + } + private func expectActivation(of processID: Int32?) { guard let processID else { return } expectedActivationGeneration &+= 1 @@ -540,7 +689,7 @@ public final class OverviewController: NSObject { monitorID: monitor.id, workspaceID: monitor.workspaces[adjacentIndex].id ) - case .select, .cancel: + case .moveUp, .moveDown, .select, .cancel: return selection } } @@ -610,19 +759,12 @@ public final class OverviewController: NSObject { } ) for (monitorID, panel) in panels { - let bounds = Rect( - x: 0, - y: 0, - width: panel.view.bounds.width, - height: panel.view.bounds.height - ) - let projection = projectOverview( - snapshot: snapshot, - monitorID: monitorID, - bounds: bounds, - viewport: viewports[monitorID] ?? OverviewViewport(), - layout: layout, - zoom: overviewZoom + let target = projection(for: panel, snapshot: snapshot) + let projection = displayedProjection( + target: target, + on: monitorID, + now: now, + reduceMotion: reduceMotion ) projections[monitorID] = projection panel.view.update( @@ -639,6 +781,55 @@ public final class OverviewController: NSObject { schedulePreviewsIfNeeded() } + private func projection( + for panel: OverviewPanel, + snapshot: OverviewSnapshot + ) -> OverviewProjection { + projectOverview( + snapshot: snapshot, + monitorID: panel.monitorID, + bounds: Rect( + x: 0, + y: 0, + width: panel.view.bounds.width, + height: panel.view.bounds.height + ), + viewport: viewports[panel.monitorID] ?? OverviewViewport(), + layout: layout, + zoom: overviewZoom + ) + } + + private func displayedProjection( + target: OverviewProjection, + on monitorID: MonitorID, + now: TimeInterval, + reduceMotion: Bool + ) -> OverviewProjection { + guard animationsEnabled, !reduceMotion, + let animation = projectionAnimations[monitorID] + else { + projectionAnimations[monitorID] = nil + return target + } + let elapsed = now - animation.startedAt + guard elapsed < animation.duration else { + projectionAnimations[monitorID] = nil + return target + } + return interpolateOverviewProjection( + from: animation.from, + to: animation.to, + progress: animatedScalar( + from: 0, + to: 1, + elapsed: elapsed, + duration: animation.duration + ), + foregroundWindowID: selection?.windowID + ) + } + private func schedulePreviewsIfNeeded() { guard windowPreviewsEnabled, isOpen, previewTask == nil, previewPermissionState != .denied @@ -910,9 +1101,9 @@ public final class OverviewController: NSObject { guard current != target else { return } guard animationsEnabled, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, - let screen = screen(for: monitorID) + let link = displayLink(on: monitorID) else { - cancelViewportAnimation(on: monitorID) + cancelAnimations(on: monitorID) viewports[monitorID] = target updatePanels() return @@ -923,70 +1114,95 @@ public final class OverviewController: NSObject { startedAt: CACurrentMediaTime(), duration: 0.16 ) - let link: CADisplayLink - if let existing = viewportDisplayLinks[monitorID] { - link = existing - } else { - link = screen.displayLink( - target: self, - selector: #selector(viewportDisplayLinkDidFire(_:)) - ) - link.add(to: .main, forMode: .common) - viewportDisplayLinks[monitorID] = link - displayLinkMonitorIDs[ObjectIdentifier(link)] = monitorID - } + projectionAnimations[monitorID] = nil link.isPaused = false } - @objc private func viewportDisplayLinkDidFire(_ link: CADisplayLink) { - guard let monitorID = displayLinkMonitorIDs[ObjectIdentifier(link)], - let animation = viewportAnimations[monitorID] + private func animateProjection( + on monitorID: MonitorID, + from source: OverviewProjection? + ) { + guard let source, let snapshot, let panel = panels[monitorID] else { return } + let target = projection(for: panel, snapshot: snapshot) + guard source != target, + animationsEnabled, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + let link = displayLink(on: monitorID) else { - link.isPaused = true + projectionAnimations[monitorID] = nil return } - if !animationsEnabled || NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { - viewports[monitorID] = animation.to - viewportAnimations[monitorID] = nil + projectionAnimations[monitorID] = OverviewProjectionAnimation( + from: source, + to: target, + startedAt: CACurrentMediaTime(), + duration: 0.16 + ) + link.isPaused = false + } + + private func displayLink(on monitorID: MonitorID) -> CADisplayLink? { + if let existing = viewportDisplayLinks[monitorID] { return existing } + guard let screen = screen(for: monitorID) else { return nil } + let link = screen.displayLink( + target: self, + selector: #selector(viewportDisplayLinkDidFire(_:)) + ) + link.add(to: .main, forMode: .common) + viewportDisplayLinks[monitorID] = link + displayLinkMonitorIDs[ObjectIdentifier(link)] = monitorID + return link + } + + @objc private func viewportDisplayLinkDidFire(_ link: CADisplayLink) { + guard let monitorID = displayLinkMonitorIDs[ObjectIdentifier(link)] else { link.isPaused = true - updatePanels() return } - let elapsed = CACurrentMediaTime() - animation.startedAt - if elapsed >= animation.duration { - viewports[monitorID] = animation.to - viewportAnimations[monitorID] = nil - link.isPaused = true - } else { - let progress = animatedScalar( - from: 0, - to: 1, - elapsed: elapsed, - duration: animation.duration - ) - viewports[monitorID] = interpolateOverviewViewport( - from: animation.from, - to: animation.to, - progress: progress - ) + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + if let animation = viewportAnimations[monitorID] { + let elapsed = CACurrentMediaTime() - animation.startedAt + if !animationsEnabled || reduceMotion || elapsed >= animation.duration { + viewports[monitorID] = animation.to + viewportAnimations[monitorID] = nil + } else { + let progress = animatedScalar( + from: 0, + to: 1, + elapsed: elapsed, + duration: animation.duration + ) + viewports[monitorID] = interpolateOverviewViewport( + from: animation.from, + to: animation.to, + progress: progress + ) + } } updatePanels() + if viewportAnimations[monitorID] == nil, + projectionAnimations[monitorID] == nil + { + link.isPaused = true + } } - private func cancelViewportAnimation(on monitorID: MonitorID) { + private func cancelAnimations(on monitorID: MonitorID) { viewportAnimations[monitorID] = nil + projectionAnimations[monitorID] = nil viewportDisplayLinks[monitorID]?.isPaused = true } - private func stopViewportAnimations() { + private func stopOverviewAnimations() { viewportAnimations.removeAll(keepingCapacity: true) + projectionAnimations.removeAll(keepingCapacity: true) for link in viewportDisplayLinks.values { link.invalidate() } viewportDisplayLinks.removeAll(keepingCapacity: true) displayLinkMonitorIDs.removeAll(keepingCapacity: true) } private func closePanelsImmediately() { - stopViewportAnimations() + stopOverviewAnimations() for panel in panels.values { panel.close() } panels.removeAll(keepingCapacity: true) isOpen = false @@ -1053,7 +1269,7 @@ extension OverviewController: OverviewViewDelegate { where: { $0.windowID == windowID && $0.canDrag } ) else { return } - cancelViewportAnimation(on: view.monitorID) + cancelAnimations(on: view.monitorID) drag = OverviewDrag( windowID: windowID, appID: window.appID, @@ -1084,21 +1300,12 @@ extension OverviewController: OverviewViewDelegate { self.drag = nil updatePanels() guard let target = drag.target else { return } - let location = target.location - selection = .window( + commitOverviewDrop( windowID: drag.windowID, - monitorID: location.monitorID, - workspaceID: location.workspaceID - ) - alignSelectionOnNextUpdate = true - expectActivation(of: snapshot?.windows[drag.windowID]?.processID) - activateMonitorHandler(location.monitorID) - dropHandler( - drag.windowID, - drag.appID, - drag.sourceMonitorID, - drag.sourceWorkspaceID, - target + appID: drag.appID, + sourceMonitorID: drag.sourceMonitorID, + sourceWorkspaceID: drag.sourceWorkspaceID, + target: target ) } @@ -1123,7 +1330,7 @@ extension OverviewController: OverviewViewDelegate { }) ?? 0 let viewport: OverviewViewport if hasPreciseScrollingDeltas { - cancelViewportAnimation(on: view.monitorID) + cancelAnimations(on: view.monitorID) viewport = viewports[view.monitorID] ?? OverviewViewport() } else { viewport = viewportAnimations[view.monitorID]?.to @@ -1166,7 +1373,7 @@ extension OverviewController: OverviewViewDelegate { let maximumOffset = maximumHorizontalOffset(for: workspaceID, on: monitor) else { return } activateMonitorHandler(view.monitorID) - cancelViewportAnimation(on: view.monitorID) + cancelAnimations(on: view.monitorID) let activeIndex = monitor.workspaces.firstIndex(where: { $0.id == monitor.activeWorkspace }) ?? 0 @@ -1230,6 +1437,31 @@ extension OverviewController: OverviewViewDelegate { updatePanels() } + private func commitOverviewDrop( + windowID: WindowID, + appID: String, + sourceMonitorID: MonitorID, + sourceWorkspaceID: WorkspaceID, + target: OverviewDropTarget + ) { + let location = target.location + selection = .window( + windowID: windowID, + monitorID: location.monitorID, + workspaceID: location.workspaceID + ) + alignSelectionOnNextUpdate = true + expectActivation(of: snapshot?.windows[windowID]?.processID) + activateMonitorHandler(location.monitorID) + dropHandler( + windowID, + appID, + sourceMonitorID, + sourceWorkspaceID, + target + ) + } + private func activateEdgeScroll(for panel: OverviewPanel, localY: Double) { let margin = 56.0 let direction: Double @@ -1257,7 +1489,7 @@ extension OverviewController: OverviewViewDelegate { $0.id == panel.monitorID }) else { return } - self.cancelViewportAnimation(on: panel.monitorID) + self.cancelAnimations(on: panel.monitorID) let activeIndex = monitor.workspaces.firstIndex(where: { $0.id == monitor.activeWorkspace }) ?? 0 @@ -1311,6 +1543,13 @@ private struct OverviewLocation: Equatable { let workspaceID: WorkspaceID } +private struct OverviewTiledPosition: Equatable { + let monitorID: MonitorID + let workspaceID: WorkspaceID + let columnIndex: Int + let windowIndex: Int +} + private extension OverviewSnapshot { func location(of windowID: WindowID) -> OverviewLocation? { for monitor in monitors { @@ -1323,6 +1562,23 @@ private extension OverviewSnapshot { } return nil } + + func tiledPosition(of windowID: WindowID) -> OverviewTiledPosition? { + for monitor in monitors { + for workspace in monitor.workspaces { + for (columnIndex, column) in workspace.columns.enumerated() { + guard let windowIndex = column.windows.firstIndex(of: windowID) else { continue } + return OverviewTiledPosition( + monitorID: monitor.id, + workspaceID: workspace.id, + columnIndex: columnIndex, + windowIndex: windowIndex + ) + } + } + } + return nil + } } private struct OverviewDrag { @@ -1402,7 +1658,12 @@ private final class OverviewPanel { let view: OverviewView private let desktopView: NSView - init(monitorID: MonitorID, screen: NSScreen, delegate: OverviewViewDelegate) { + init( + monitorID: MonitorID, + screen: NSScreen, + usesCapturedDesktop: Bool, + delegate: OverviewViewDelegate + ) { self.monitorID = monitorID view = OverviewView(monitorID: monitorID, delegate: delegate) desktopView = NSView(frame: NSRect(origin: .zero, size: screen.frame.size)) @@ -1416,14 +1677,14 @@ private final class OverviewPanel { window.setFrame(screen.frame, display: false) window.title = "Defi Overview" window.setAccessibilityLabel("Defi Overview") - window.isOpaque = true - window.backgroundColor = .black + window.isOpaque = usesCapturedDesktop + window.backgroundColor = usesCapturedDesktop ? .black : .clear window.hasShadow = false window.hidesOnDeactivate = false window.isReleasedWhenClosed = false window.isExcludedFromWindowsMenu = true window.animationBehavior = .none - window.level = .floating + window.level = .statusBar window.collectionBehavior = [ .canJoinAllSpaces, .fullScreenAuxiliary, @@ -1433,15 +1694,20 @@ private final class OverviewPanel { window.sharingType = .readOnly let rootView = NSView(frame: NSRect(origin: .zero, size: screen.frame.size)) rootView.wantsLayer = true - rootView.layer?.backgroundColor = NSColor.black.cgColor + rootView.layer?.backgroundColor = usesCapturedDesktop + ? NSColor.black.cgColor + : NSColor.clear.cgColor rootView.autoresizingMask = [.width, .height] desktopView.wantsLayer = true - desktopView.layer?.backgroundColor = NSColor.black.cgColor + desktopView.layer?.backgroundColor = usesCapturedDesktop + ? NSColor.black.cgColor + : NSColor.clear.cgColor desktopView.layer?.contentsGravity = .resizeAspectFill desktopView.layer?.contentsScale = screen.backingScaleFactor desktopView.layer?.masksToBounds = true desktopView.autoresizingMask = [.width, .height] - if let url = NSWorkspace.shared.desktopImageURL(for: screen), + if usesCapturedDesktop, + let url = NSWorkspace.shared.desktopImageURL(for: screen), let image = NSImage(contentsOf: url) { desktopView.layer?.contents = image @@ -1451,7 +1717,6 @@ private final class OverviewPanel { ) glassView.style = .regular glassView.appearance = NSAppearance(named: .darkAqua) - glassView.tintColor = NSColor.black.withAlphaComponent(0.58) glassView.autoresizingMask = [.width, .height] view.frame = glassView.bounds view.autoresizingMask = [.width, .height] @@ -1466,16 +1731,21 @@ private final class OverviewPanel { } func show(animated: Bool) { - window.alphaValue = animated ? 0 : 1 + window.alphaValue = animated ? 0.001 : 1 view.wantsLayer = true view.layer?.setAffineTransform(animated ? CGAffineTransform(scaleX: 0.97, y: 0.97) : .identity) window.orderFrontRegardless() guard animated else { return } - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.16 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().alphaValue = 1 - view.layer?.setAffineTransform(.identity) + window.displayIfNeeded() + let displayInterval = 1 / Double(max(window.screen?.maximumFramesPerSecond ?? 60, 60)) + DispatchQueue.main.asyncAfter(deadline: .now() + displayInterval) { [weak self] in + guard let self else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + self.window.animator().alphaValue = 1 + self.view.layer?.setAffineTransform(.identity) + } } } @@ -1565,6 +1835,14 @@ private final class OverviewView: NSView { for workspace in projection.workspaces { drawWorkspace(workspace, snapshot: snapshot) } + if let overlayWindowID = projection.overlayWindowID, + let overlay = projection.workspaces.lazy.flatMap(\.windows).first(where: { + $0.windowID == overlayWindowID + }) + { + drawWindow(overlay, snapshot: snapshot) + drawWindowBorder(overlay) + } drawDraggedCard(snapshot: snapshot) drawDropTarget() } @@ -1615,14 +1893,20 @@ private final class OverviewView: NSView { let frame = nsRect(workspace.frame) NSGraphicsContext.saveGraphicsState() frame.clip() - for window in workspace.windows where drag?.windowID != window.windowID { + for window in workspace.windows + where drag?.windowID != window.windowID + && projection?.overlayWindowID != window.windowID + { drawWindow(window, snapshot: snapshot) } NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.saveGraphicsState() frame.insetBy(dx: -borderStyle.width, dy: -borderStyle.width).clip() - for window in workspace.windows where drag?.windowID != window.windowID { + for window in workspace.windows + where drag?.windowID != window.windowID + && projection?.overlayWindowID != window.windowID + { drawWindowBorder(window) } NSGraphicsContext.restoreGraphicsState() diff --git a/Tests/DefiCoreTests/OverviewTests.swift b/Tests/DefiCoreTests/OverviewTests.swift index 0850c09..69ff566 100644 --- a/Tests/DefiCoreTests/OverviewTests.swift +++ b/Tests/DefiCoreTests/OverviewTests.swift @@ -47,6 +47,48 @@ struct OverviewTests { #expect(snapshot.monitors[0].workspaces[4].scrollOffset == 0.25) } + @Test + func `Adjacent active workspace projections retain entering and leaving ribbons`() { + let workspaceIDs = (1...9).map { WorkspaceID(rawValue: String($0)) } + let workspaces = workspaceIDs.map { Workspace(id: $0) } + func projection(activeIndex: Int) -> OverviewProjection { + projectOverview( + snapshot: OverviewSnapshot( + monitors: [ + Monitor( + id: monitorID, + workspaces: workspaces, + activeWorkspace: workspaceIDs[activeIndex] + ) + ], + monitorFrames: [monitorID: monitorFrame], + windows: [:] + ), + monitorID: monitorID, + bounds: monitorFrame, + viewport: OverviewViewport(), + layout: LayoutSettings() + ) + } + let source = projection(activeIndex: 4) + let target = projection(activeIndex: 5) + let sourceIDs = Set(source.workspaces.map(\.workspaceID)) + let targetIDs = Set(target.workspaces.map(\.workspaceID)) + let isVisible: (OverviewWorkspaceProjection) -> Bool = { + $0.frame.y < monitorFrame.y + monitorFrame.height + && $0.frame.y + $0.frame.height > monitorFrame.y + } + let sourceVisibleIDs = Set( + source.workspaces.filter(isVisible).map(\.workspaceID) + ) + let targetVisibleIDs = Set( + target.workspaces.filter(isVisible).map(\.workspaceID) + ) + + #expect(targetVisibleIDs.isSubset(of: sourceIDs)) + #expect(sourceVisibleIDs.isSubset(of: targetIDs)) + } + @Test func `Workspace projects as a centered half-height ribbon`() { let windowID = WindowID(rawValue: 1) @@ -309,6 +351,137 @@ struct OverviewTests { #expect(viewport.horizontalOffsets[third] == 3) } + @Test + func `Projection interpolation follows reordered windows`() throws { + let workspaceID = WorkspaceID(rawValue: "dev") + let first = WindowID(rawValue: 1) + let second = WindowID(rawValue: 2) + func card(_ windowID: WindowID, x: Double, column: Int) -> OverviewWindowProjection { + OverviewWindowProjection( + windowID: windowID, + frame: Rect(x: x, y: 0, width: 100, height: 100), + layer: .tiled(columnIndex: column, windowIndex: 0), + isNativeFullscreen: false, + canDrag: true + ) + } + func projection(_ windows: [OverviewWindowProjection]) -> OverviewProjection { + OverviewProjection( + monitorID: monitorID, + workspaces: [ + OverviewWorkspaceProjection( + workspaceID: workspaceID, + frame: monitorFrame, + windows: windows + ) + ] + ) + } + let source = projection([ + card(first, x: 0, column: 0), + card(second, x: 100, column: 1), + ]) + let target = projection([ + card(second, x: 0, column: 0), + card(first, x: 100, column: 1), + ]) + + let middle = interpolateOverviewProjection(from: source, to: target, progress: 0.5) + let windows = try #require(middle.workspaces.first?.windows) + let movedFirst = try #require(windows.first(where: { $0.windowID == first })) + let movedSecond = try #require(windows.first(where: { $0.windowID == second })) + + #expect(movedFirst.frame.x == 50) + #expect(movedSecond.frame.x == 50) + #expect(movedFirst.layer == .tiled(columnIndex: 1, windowIndex: 0)) + + let returning = interpolateOverviewProjection( + from: target, + to: source, + progress: 0.5, + foregroundWindowID: first + ) + #expect( + returning.hitTest(OverviewPoint(x: 50, y: 50)) + == .window(windowID: first, monitorID: monitorID, workspaceID: workspaceID) + ) + } + + @Test + func `Projection interpolation follows a window between workspaces`() throws { + let firstWorkspace = WorkspaceID(rawValue: "1") + let secondWorkspace = WorkspaceID(rawValue: "2") + let windowID = WindowID(rawValue: 1) + let card = OverviewWindowProjection( + windowID: windowID, + frame: Rect(x: 0, y: 0, width: 100, height: 100), + layer: .tiled(columnIndex: 0, windowIndex: 0), + isNativeFullscreen: false, + canDrag: true + ) + let source = OverviewProjection( + monitorID: monitorID, + workspaces: [ + OverviewWorkspaceProjection( + workspaceID: firstWorkspace, + frame: Rect(x: 0, y: 0, width: 100, height: 100), + windows: [card] + ), + OverviewWorkspaceProjection( + workspaceID: secondWorkspace, + frame: Rect(x: 0, y: 100, width: 100, height: 100), + windows: [] + ), + ] + ) + let target = OverviewProjection( + monitorID: monitorID, + workspaces: [ + OverviewWorkspaceProjection( + workspaceID: firstWorkspace, + frame: Rect(x: 0, y: 0, width: 100, height: 100), + windows: [] + ), + OverviewWorkspaceProjection( + workspaceID: secondWorkspace, + frame: Rect(x: 0, y: 100, width: 100, height: 100), + windows: [ + OverviewWindowProjection( + windowID: windowID, + frame: Rect(x: 0, y: 100, width: 100, height: 100), + layer: card.layer, + isNativeFullscreen: false, + canDrag: true + ) + ] + ), + ] + ) + + let middle = interpolateOverviewProjection( + from: source, + to: target, + progress: 0.5, + foregroundWindowID: windowID + ) + let start = interpolateOverviewProjection( + from: source, + to: target, + progress: 0, + foregroundWindowID: windowID + ) + + #expect(middle.workspaces[1].windows[0].frame.y == 50) + #expect( + start.hitTest(OverviewPoint(x: 50, y: 50)) + == .window( + windowID: windowID, + monitorID: monitorID, + workspaceID: secondWorkspace + ) + ) + } + @Test func `Drop target distinguishes stacks and new columns`() { let first = WindowID(rawValue: 1) diff --git a/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift b/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift index 06f334d..ad259d2 100644 --- a/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift +++ b/Tests/DefiDaemonTests/DaemonCommandPolicyTests.swift @@ -5,6 +5,26 @@ import Testing @testable import DefiDaemon struct DaemonCommandPolicyTests { + @Test + func overviewIgnoresParkingFocusWithoutNewFocusInput() { + #expect( + !shouldCloseOverviewAfterNativeFocusChange( + nativeFocusChanged: true, + overviewOpenedAt: 10, + mouseFocusIntentTimestamp: 9, + keyboardFocusIntentTimestamp: nil + ) + ) + #expect( + shouldCloseOverviewAfterNativeFocusChange( + nativeFocusChanged: true, + overviewOpenedAt: 10, + mouseFocusIntentTimestamp: nil, + keyboardFocusIntentTimestamp: 11 + ) + ) + } + @Test func localCommandResubmitsMonitorsWithInFlightAnimation() { let animatedMonitor = MonitorID(rawValue: 1) diff --git a/Tests/DefiMacOSTests/HotKeyTests.swift b/Tests/DefiMacOSTests/HotKeyTests.swift index 80cfc90..edfd844 100644 --- a/Tests/DefiMacOSTests/HotKeyTests.swift +++ b/Tests/DefiMacOSTests/HotKeyTests.swift @@ -6,7 +6,7 @@ import Testing @Suite struct OverviewHotKeyTests { @Test - func `Overview captures unmodified and Hyper arrows`() { + func `Overview captures navigation arrows but leaves move bindings active`() { let hyper = hotKeyModifierBits([ .maskAlternate, .maskCommand, @@ -18,16 +18,37 @@ struct OverviewHotKeyTests { overviewKeyAction( keyCode: 123, modifierBits: hyper, - isConfiguredBinding: true + configuredCommand: "focus-column left" ) == .left ) #expect(overviewKeyAction(keyCode: 123, modifierBits: hyper) == nil) + #expect( + overviewKeyAction( + keyCode: 123, + modifierBits: hyper | hotKeyModifierBits([.maskShift]), + configuredCommand: "move-column left" + ) == nil + ) + #expect( + overviewKeyAction( + keyCode: 126, + modifierBits: hyper | hotKeyModifierBits([.maskShift]), + configuredCommand: "move-window up" + ) == .moveUp + ) + #expect( + overviewKeyAction( + keyCode: 125, + modifierBits: hyper | hotKeyModifierBits([.maskShift]), + configuredCommand: "move-window down" + ) == .moveDown + ) #expect(overviewKeyAction(keyCode: 36, modifierBits: 0) == .select) #expect( overviewKeyAction( keyCode: 36, modifierBits: hyper, - isConfiguredBinding: true + configuredCommand: "focus-column left" ) == nil ) #expect(overviewKeyAction(keyCode: 53, modifierBits: 0) == .cancel) diff --git a/Tests/DefiMacOSTests/OverviewPreviewTests.swift b/Tests/DefiMacOSTests/OverviewPreviewTests.swift index 3c09f8d..c3f1723 100644 --- a/Tests/DefiMacOSTests/OverviewPreviewTests.swift +++ b/Tests/DefiMacOSTests/OverviewPreviewTests.swift @@ -4,6 +4,28 @@ import Testing @testable import DefiMacOS struct OverviewPreviewTests { + @Test("Overview parks windows when captured desktop is unavailable") + func overviewBackdropFallbackPolicy() { + #expect( + overviewUsesWorkspaceParking( + windowPreviewsEnabled: false, + screenCaptureAccessGranted: true + ) + ) + #expect( + overviewUsesWorkspaceParking( + windowPreviewsEnabled: true, + screenCaptureAccessGranted: false + ) + ) + #expect( + !overviewUsesWorkspaceParking( + windowPreviewsEnabled: true, + screenCaptureAccessGranted: true + ) + ) + } + @Test func `Capture scheduler never exceeds its bound and preserves order`() async { let probe = CaptureProbe() diff --git a/Tests/DefiMacOSTests/OverviewScrollTests.swift b/Tests/DefiMacOSTests/OverviewScrollTests.swift index 09d73d7..0e7fe4f 100644 --- a/Tests/DefiMacOSTests/OverviewScrollTests.swift +++ b/Tests/DefiMacOSTests/OverviewScrollTests.swift @@ -117,4 +117,25 @@ struct OverviewScrollTests { ) < 0.000_001 ) } + + @Test + func `Moving a selection clamps its source ribbon and discards the stale target`() { + let sourceWorkspaceID = WorkspaceID(rawValue: "dev") + let transition = overviewViewportTransitionAfterSelectionAlignment( + current: OverviewViewport(horizontalOffsets: [workspaceID: 0, sourceWorkspaceID: 2]), + pendingTarget: OverviewViewport( + horizontalOffsets: [workspaceID: 0, sourceWorkspaceID: 2] + ), + animationTarget: nil, + workspaceID: workspaceID, + scrollOffset: 1, + sourceWorkspaceID: sourceWorkspaceID, + sourceMaximumHorizontalOffset: 1, + movedSelection: true + ) + + #expect(transition.current.horizontalOffsets[workspaceID] == 1) + #expect(transition.current.horizontalOffsets[sourceWorkspaceID] == 1) + #expect(transition.target == nil) + } }