diff --git a/Sources/AppDelegate+ShortcutActions.swift b/Sources/AppDelegate+ShortcutActions.swift index 8954b6003..fe8d1890a 100644 --- a/Sources/AppDelegate+ShortcutActions.swift +++ b/Sources/AppDelegate+ShortcutActions.swift @@ -49,7 +49,7 @@ extension AppDelegate { targetWindow.toggleFullScreen(nil) return true }), - (.toggleSidebar, consume { app, _, _ in _ = app.toggleSidebarInActiveMainWindow() }), + (.toggleSidebar, consume { app, _, _ in _ = app.toggleSidebarInActiveMainWindow(animated: false) }), (.newTab, consume { app, event, _ in app.performNewWorkspaceShortcut(event: event) }), // New Claude Code Workspace: Cmd+Shift+C. Instantly creates a new workspace in the current // workspace's working directory and boots Claude Code into it. diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d9a12094e..78ae7321f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2849,7 +2849,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } @discardableResult - func toggleSidebarInActiveMainWindow() -> Bool { + func toggleSidebarInActiveMainWindow(animated: Bool = true) -> Bool { let activeContext = tabManager.flatMap { activeManager in mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) } @@ -2860,11 +2860,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser if let window = context.window ?? windowForMainWindowId(context.windowId) { setActiveMainWindow(window) } - context.sidebarState.toggle() + context.sidebarState.toggle(animated: animated) return true } if let sidebarState { - sidebarState.toggle() + sidebarState.toggle(animated: animated) return true } return false diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift index acf21391f..c8675d3bb 100644 --- a/Sources/Find/SurfaceSearchOverlay.swift +++ b/Sources/Find/SurfaceSearchOverlay.swift @@ -182,7 +182,7 @@ struct SurfaceSearchOverlay: View { y: centerPos.y + value.translation.height ) let newCorner = closestCorner(to: newCenter, in: geo.size) - withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) { + withAnimation(reduceMotion ? nil : .spring(duration: 0.35, bounce: 0.15)) { corner = newCorner dragOffset = .zero } diff --git a/Sources/GhosttySurfaceScrollView.swift b/Sources/GhosttySurfaceScrollView.swift index b4b28810f..7f8be775c 100644 --- a/Sources/GhosttySurfaceScrollView.swift +++ b/Sources/GhosttySurfaceScrollView.swift @@ -1496,7 +1496,7 @@ final class GhosttySurfaceScrollView: NSView { } NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + context.timingFunction = CAMediaTimingFunction(controlPoints: 0.23, 1, 0.32, 1) if needsFrameUpdate && !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { dropZoneOverlayView.animator().frame = targetFrame } @@ -1580,13 +1580,23 @@ final class GhosttySurfaceScrollView: NSView { #endif self.updateFlashPath(style: style) self.updateFlashAppearance(style: style) + let flash = FocusFlashPattern.timeline( + reduceMotion: NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + ) + var values = flash.values + // A retrigger mid-flash starts from the ring's visible opacity instead of cutting to 0. + if !values.isEmpty, + self.flashLayer.animation(forKey: "programa.flash") != nil, + let liveOpacity = self.flashLayer.presentation()?.opacity { + values[0] = Double(liveOpacity) + } self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") - animation.values = FocusFlashPattern.values.map { NSNumber(value: $0) } - animation.keyTimes = FocusFlashPattern.keyTimes.map { NSNumber(value: $0) } - animation.duration = FocusFlashPattern.duration - animation.timingFunctions = FocusFlashPattern.curves.map { curve in + animation.values = values.map { NSNumber(value: $0) } + animation.keyTimes = flash.keyTimes.map { NSNumber(value: $0) } + animation.duration = flash.duration + animation.timingFunctions = flash.curves.map { curve in switch curve { case .easeIn: return CAMediaTimingFunction(name: .easeIn) @@ -2585,6 +2595,14 @@ final class GhosttySurfaceScrollView: NSView { inset: inset, radius: radius ) + // The fill is clear, so the glow follows the stroke ring; an explicit stroked path keeps + // Core Animation from deriving the shadow from the layer's alpha offscreen every frame. + flashLayer.shadowPath = flashLayer.path?.copy( + strokingWithWidth: flashLayer.lineWidth, + lineCap: .round, + lineJoin: .round, + miterLimit: flashLayer.miterLimit + ) } private func updateFlashAppearance(style: FlashStyle) { diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index c476d22f5..b12b45c5d 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -11,6 +11,7 @@ struct MarkdownPanelView: View { let onRequestPanelFocus: () -> Void @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Group { @@ -24,14 +25,15 @@ struct MarkdownPanelView: View { .background(backgroundColor) .overlay { // One transaction keeps SwiftUI from coalescing or interrupting the sequence. - PhaseAnimator(FocusFlashPattern.values.indices, trigger: panel.focusFlashToken) { phaseIndex in + let flash = FocusFlashPattern.timeline(reduceMotion: reduceMotion) + PhaseAnimator(flash.values.indices, trigger: panel.focusFlashToken) { phaseIndex in RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) - .stroke(programaAccentColor().opacity(FocusFlashPattern.values[phaseIndex]), lineWidth: 3) - .shadow(color: programaAccentColor().opacity(FocusFlashPattern.values[phaseIndex] * 0.35), radius: 10) + .stroke(programaAccentColor().opacity(flash.opacity(atPhase: phaseIndex)), lineWidth: 3) + .shadow(color: programaAccentColor().opacity(flash.opacity(atPhase: phaseIndex) * 0.35), radius: 10) .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } animation: { phaseIndex in - FocusFlashPattern.phaseAnimation(at: phaseIndex) + flash.phaseAnimation(at: phaseIndex) } } .overlay { diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index be32d8d72..24edd8ac0 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -186,15 +186,15 @@ struct FocusFlashSegment: Equatable { let curve: FocusFlashCurve } -enum FocusFlashPattern { - static let values: [Double] = [0, 1, 0, 1, 0] - static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1] - static let duration: TimeInterval = 0.9 - static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn] - static let ringInset: Double = Double(PanelOverlayRingMetrics.inset) - static let ringCornerRadius: Double = Double(PanelOverlayRingMetrics.cornerRadius) +/// One flash timeline, shared by the terminal `CAKeyframeAnimation` and the SwiftUI +/// `PhaseAnimator` overlays so both render the same pulse. +struct FocusFlashTimeline { + let values: [Double] + let keyTimes: [Double] + let duration: TimeInterval + let curves: [FocusFlashCurve] - static var segments: [FocusFlashSegment] { + var segments: [FocusFlashSegment] { let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) return (0.. Double { + /// Bounds-checked so a `PhaseAnimator` still holding a phase from a longer timeline + /// (Reduce Motion toggled mid-flash) cannot index past the end. + func opacity(atPhase index: Int) -> Double { + values.indices.contains(index) ? values[index] : 0 + } + + func opacity(at elapsed: TimeInterval) -> Double { guard elapsed >= 0, elapsed <= duration else { return 0 } for index in 0.. Animation? { + /// from `values[index - 1]`. Plays the whole sequence as one atomic animator run per + /// `focusFlashToken` change, instead of chained `asyncAfter` + `withAnimation` steps + /// that SwiftUI can coalesce/interrupt. + func phaseAnimation(at index: Int) -> Animation? { + let segments = segments guard index > 0, index - 1 < segments.count else { return nil } let segment = segments[index - 1] switch segment.curve { @@ -256,6 +262,34 @@ enum FocusFlashPattern { } } +enum FocusFlashPattern { + static let standard = FocusFlashTimeline( + values: [0, 1, 0, 1, 0], + keyTimes: [0, 0.25, 0.5, 0.75, 1], + duration: 0.9, + curves: [.easeOut, .easeOut, .easeOut, .easeOut] + ) + /// Reduce Motion: a single shorter pulse instead of the double flash. + static let reduced = FocusFlashTimeline( + values: [0, 1, 0], + keyTimes: [0, 0.3, 1], + duration: 0.5, + curves: [.easeOut, .easeOut] + ) + static let ringInset: Double = Double(PanelOverlayRingMetrics.inset) + static let ringCornerRadius: Double = Double(PanelOverlayRingMetrics.cornerRadius) + + static var values: [Double] { standard.values } + static var keyTimes: [Double] { standard.keyTimes } + static var duration: TimeInterval { standard.duration } + static var curves: [FocusFlashCurve] { standard.curves } + static var segments: [FocusFlashSegment] { standard.segments } + + static func timeline(reduceMotion: Bool) -> FocusFlashTimeline { + reduceMotion ? reduced : standard + } +} + @MainActor public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID { var id: UUID { get } diff --git a/Sources/Panels/ReviewPanelView.swift b/Sources/Panels/ReviewPanelView.swift index 68f55a3c8..d713863dd 100644 --- a/Sources/Panels/ReviewPanelView.swift +++ b/Sources/Panels/ReviewPanelView.swift @@ -170,6 +170,7 @@ struct ReviewPanelView: View { @State private var isConfirmingCheckpointRevert = false @FocusState private var isComposerFocused: Bool @AppStorage(ReviewDiffWebView.debugToggleKey) private var renderDiffsWithPierre = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion private struct InlineComposerState { let filePath: String @@ -249,14 +250,15 @@ struct ReviewPanelView: View { } } .overlay { - PhaseAnimator(FocusFlashPattern.values.indices, trigger: panel.focusFlashToken) { phaseIndex in + let flash = FocusFlashPattern.timeline(reduceMotion: reduceMotion) + PhaseAnimator(flash.values.indices, trigger: panel.focusFlashToken) { phaseIndex in RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) - .stroke(programaAccentColor().opacity(FocusFlashPattern.values[phaseIndex]), lineWidth: 3) - .shadow(color: programaAccentColor().opacity(FocusFlashPattern.values[phaseIndex] * 0.35), radius: 10) + .stroke(programaAccentColor().opacity(flash.opacity(atPhase: phaseIndex)), lineWidth: 3) + .shadow(color: programaAccentColor().opacity(flash.opacity(atPhase: phaseIndex) * 0.35), radius: 10) .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } animation: { phaseIndex in - FocusFlashPattern.phaseAnimation(at: phaseIndex) + flash.phaseAnimation(at: phaseIndex) } } } diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift index bc74c86ac..b27bbad24 100644 --- a/Sources/TabItemView.swift +++ b/Sources/TabItemView.swift @@ -2167,7 +2167,7 @@ private struct SidebarMetadataRows: View { if shouldShowToggle { Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { onFocus() - withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.15)) { + withAnimation(reduceMotion ? nil : .snappy(duration: 0.2)) { isExpanded.toggle() } } @@ -2321,7 +2321,7 @@ private struct SidebarMetadataMarkdownBlocks: View { if shouldShowToggle { Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { onFocus() - withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.15)) { + withAnimation(reduceMotion ? nil : .snappy(duration: 0.2)) { isExpanded.toggle() } } diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 8b8b53bb9..f06986671 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -320,7 +320,8 @@ class UpdateController { guard self.viewModel.overrideState == nil, case .notFound = self.viewModel.state else { return } - withAnimation(.easeInOut(duration: 0.25)) { + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.2)) { self.recordUITestTimestamp(key: "noUpdateHiddenAt") self.viewModel.state = .idle } diff --git a/Sources/VerticalTabsSidebar.swift b/Sources/VerticalTabsSidebar.swift index 63f0f5740..bbf0712fc 100644 --- a/Sources/VerticalTabsSidebar.swift +++ b/Sources/VerticalTabsSidebar.swift @@ -37,18 +37,25 @@ final class SidebarState: ObservableObject { self.persistedWidth = CGFloat(sanitized) } - @MainActor func toggle() { - let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + /// `animated: false` is for the keyboard shortcut, which toggles without a slide; Reduce + /// Motion makes every toggle instant. + @MainActor func toggle(animated: Bool = true) { + guard animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isVisible.toggle() + } + NotificationCenter.default.post(name: .programaSidebarToggleDidSettle, object: self) + return + } // The terminal is an AppKit portal; marking the toggle as an interactive resize makes // it follow the animated SwiftUI frame every tick, as it does for a sidebar drag. TerminalWindowPortalRegistry.beginInteractiveGeometryResize() pendingSlides += 1 // `.removed` waits for the visual end: the default (logically complete) fires as soon as // another toggle retargets the slide, which ended the portal resize mid-motion. - withAnimation( - reduceMotion ? .easeOut(duration: 0.15) : .snappy(duration: 0.24), - completionCriteria: .removed - ) { + withAnimation(.snappy(duration: 0.24), completionCriteria: .removed) { isVisible.toggle() } completion: { [weak self] in guard let self else { return } @@ -469,7 +476,7 @@ struct VerticalTabsSidebar: View { DispatchQueue.main.async { guard let selectedId = tabManager.selectedTabId else { return } if animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { - withAnimation(.easeOut(duration: 0.15)) { + withAnimation(.snappy(duration: 0.2)) { scrollProxy.scrollTo(selectedId) } } else { diff --git a/programaTests/WindowAndDragTests.swift b/programaTests/WindowAndDragTests.swift index 478a7da7b..0a8d88e45 100644 --- a/programaTests/WindowAndDragTests.swift +++ b/programaTests/WindowAndDragTests.swift @@ -1057,7 +1057,7 @@ final class FocusFlashPatternTests: XCTestCase { XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0]) XCTAssertEqual(FocusFlashPattern.keyTimes, [0, 0.25, 0.5, 0.75, 1]) XCTAssertEqual(FocusFlashPattern.duration, 0.9, accuracy: 0.0001) - XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeIn, .easeOut, .easeIn]) + XCTAssertEqual(FocusFlashPattern.curves, [.easeOut, .easeOut, .easeOut, .easeOut]) XCTAssertEqual(FocusFlashPattern.ringInset, 2, accuracy: 0.0001) XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 6, accuracy: 0.0001) } @@ -1074,7 +1074,7 @@ final class FocusFlashPatternTests: XCTestCase { XCTAssertEqual(segments[1].delay, 0.225, accuracy: 0.0001) XCTAssertEqual(segments[1].duration, 0.225, accuracy: 0.0001) XCTAssertEqual(segments[1].targetOpacity, 0, accuracy: 0.0001) - XCTAssertEqual(segments[1].curve, .easeIn) + XCTAssertEqual(segments[1].curve, .easeOut) XCTAssertEqual(segments[2].delay, 0.45, accuracy: 0.0001) XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001) @@ -1084,7 +1084,7 @@ final class FocusFlashPatternTests: XCTestCase { XCTAssertEqual(segments[3].delay, 0.675, accuracy: 0.0001) XCTAssertEqual(segments[3].duration, 0.225, accuracy: 0.0001) XCTAssertEqual(segments[3].targetOpacity, 0, accuracy: 0.0001) - XCTAssertEqual(segments[3].curve, .easeIn) + XCTAssertEqual(segments[3].curve, .easeOut) } } diff --git a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift index 466a196a7..865fae48c 100644 --- a/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift +++ b/vendor/bonsplit/Sources/Bonsplit/Internal/Views/PaneContainerView.swift @@ -63,7 +63,7 @@ private struct PaneDropPlaceholderOverlay: View { .frame(width: frame.width, height: frame.height) .offset(x: frame.minX, y: frame.minY) .opacity(zone != nil ? 1 : 0) - .animation(reduceMotion ? nil : .easeOut(duration: 0.18), value: zone) + .animation(reduceMotion ? nil : .snappy(duration: 0.2), value: zone) } private func overlayFrame(for zone: DropZone?, in size: CGSize) -> CGRect {