Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/AppDelegate+ShortcutActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/Find/SurfaceSearchOverlay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
28 changes: 23 additions & 5 deletions Sources/GhosttySurfaceScrollView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 6 additions & 4 deletions Sources/Panels/MarkdownPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
64 changes: 49 additions & 15 deletions Sources/Panels/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<stepCount).map { index in
let startTime = keyTimes[index]
Expand All @@ -208,7 +208,13 @@ enum FocusFlashPattern {
}
}

static func opacity(at elapsed: TimeInterval) -> 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..<segments.count {
Expand All @@ -220,7 +226,7 @@ enum FocusFlashPattern {

let segmentDuration = max(endTime - startTime, 0.0001)
let rawProgress = max(0, min(1, (elapsed - startTime) / segmentDuration))
let curvedProgress = interpolatedProgress(rawProgress, curve: curves[index])
let curvedProgress = Self.interpolatedProgress(rawProgress, curve: curves[index])
let startOpacity = values[index]
let endOpacity = values[index + 1]
return startOpacity + ((endOpacity - startOpacity) * curvedProgress)
Expand All @@ -240,11 +246,11 @@ enum FocusFlashPattern {
}

/// SwiftUI `Animation` to use when a `PhaseAnimator` transitions *into* `values[index]`
/// from `values[index - 1]`. Drives the single-transaction double-flash used by
/// `MarkdownPanelView` so the whole [0,1,0,1,0] sequence plays as one
/// atomic animator run per `focusFlashToken` change, instead of four chained
/// `asyncAfter` + `withAnimation` steps that SwiftUI can coalesce/interrupt.
static func phaseAnimation(at index: Int) -> 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 {
Expand All @@ -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 }
Expand Down
10 changes: 6 additions & 4 deletions Sources/Panels/ReviewPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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()
}
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/Update/UpdateController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
21 changes: 14 additions & 7 deletions Sources/VerticalTabsSidebar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions programaTests/WindowAndDragTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading