From a3f3e20d72c176f4cb54d1536d227794e2acd8a6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:31:22 -0800 Subject: [PATCH 1/2] Unify Cmd+Shift+H flash path across panel types --- Sources/GhosttyTerminalView.swift | 20 ++++++----- Sources/Panels/BrowserPanelView.swift | 31 +++++++++------- Sources/Panels/Panel.swift | 36 +++++++++++++++++++ Sources/Workspace.swift | 9 +---- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 34 ++++++++++++++++++ 5 files changed, 101 insertions(+), 29 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2b80a7f3..f4a83671 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3884,15 +3884,17 @@ final class GhosttySurfaceScrollView: NSView { self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") - animation.values = [0, 1, 0, 1, 0] - animation.keyTimes = [0, 0.25, 0.5, 0.75, 1] - animation.duration = 0.9 - animation.timingFunctions = [ - CAMediaTimingFunction(name: .easeOut), - CAMediaTimingFunction(name: .easeIn), - CAMediaTimingFunction(name: .easeOut), - CAMediaTimingFunction(name: .easeIn) - ] + 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 + switch curve { + case .easeIn: + return CAMediaTimingFunction(name: .easeIn) + case .easeOut: + return CAMediaTimingFunction(name: .easeOut) + } + } self.flashLayer.add(animation, forKey: "cmux.flash") } } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 027edb1b..d4f9b2ba 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -190,7 +190,7 @@ struct BrowserPanelView: View { @State private var omnibarHasMarkedText: Bool = false @State private var suppressNextFocusLostRevert: Bool = false @State private var focusFlashOpacity: Double = 0.0 - @State private var focusFlashFadeWorkItem: DispatchWorkItem? + @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var isBrowserThemeMenuPresented = false @@ -692,20 +692,27 @@ struct BrowserPanelView: View { } private func triggerFocusFlashAnimation() { - focusFlashFadeWorkItem?.cancel() - focusFlashFadeWorkItem = nil + focusFlashAnimationGeneration &+= 1 + let generation = focusFlashAnimationGeneration + focusFlashOpacity = FocusFlashPattern.values.first ?? 0 - withAnimation(.easeOut(duration: 0.08)) { - focusFlashOpacity = 1.0 - } - - let item = DispatchWorkItem { - withAnimation(.easeOut(duration: 0.35)) { - focusFlashOpacity = 0.0 + for segment in FocusFlashPattern.segments { + DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) { + guard focusFlashAnimationGeneration == generation else { return } + withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) { + focusFlashOpacity = segment.targetOpacity + } } } - focusFlashFadeWorkItem = item - DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: item) + } + + private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation { + switch curve { + case .easeIn: + return .easeIn(duration: duration) + case .easeOut: + return .easeOut(duration: duration) + } } private func syncURLFromPanel() { diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 427d53c8..4a9f62ff 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -7,6 +7,39 @@ public enum PanelType: String, Codable, Sendable { case browser } +enum FocusFlashCurve: Equatable { + case easeIn + case easeOut +} + +struct FocusFlashSegment: Equatable { + let delay: TimeInterval + let duration: TimeInterval + let targetOpacity: Double + 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 var segments: [FocusFlashSegment] { + let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) + return (0.. NSEvent { From e4379a136c7287984029d058d55d40cecd4a1eec Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:37:48 -0800 Subject: [PATCH 2/2] Match terminal flash ring padding to browser --- Sources/GhosttyTerminalView.swift | 23 +++++++++++++++---- Sources/Panels/BrowserPanelView.swift | 4 ++-- Sources/Panels/Panel.swift | 2 ++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 2 ++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f4a83671..71258f18 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4429,16 +4429,29 @@ final class GhosttySurfaceScrollView: NSView { } private func updateNotificationRingPath() { - updateOverlayRingPath(layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds) + updateOverlayRingPath( + layer: notificationRingLayer, + bounds: notificationRingOverlayView.bounds, + inset: 2, + radius: 6 + ) } private func updateFlashPath() { - updateOverlayRingPath(layer: flashLayer, bounds: flashOverlayView.bounds) + updateOverlayRingPath( + layer: flashLayer, + bounds: flashOverlayView.bounds, + inset: CGFloat(FocusFlashPattern.ringInset), + radius: CGFloat(FocusFlashPattern.ringCornerRadius) + ) } - private func updateOverlayRingPath(layer: CAShapeLayer, bounds: CGRect) { - let inset: CGFloat = 2 - let radius: CGFloat = 6 + private func updateOverlayRingPath( + layer: CAShapeLayer, + bounds: CGRect, + inset: CGFloat, + radius: CGFloat + ) { layer.frame = bounds guard bounds.width > inset * 2, bounds.height > inset * 2 else { layer.path = nil diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index d4f9b2ba..f0c65a81 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -255,10 +255,10 @@ struct BrowserPanelView: View { webView } .overlay { - RoundedRectangle(cornerRadius: 10) + RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) .stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3) .shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10) - .padding(6) + .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } .overlay(alignment: .topLeading) { diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 4a9f62ff..a0a719c4 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -24,6 +24,8 @@ enum FocusFlashPattern { 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 = 6 + static let ringCornerRadius: Double = 10 static var segments: [FocusFlashSegment] { let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 689b736a..45e2a54e 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -213,6 +213,8 @@ final class FocusFlashPatternTests: XCTestCase { 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.ringInset, 6, accuracy: 0.0001) + XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001) } func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() {