Merge pull request #354 from manaflow-ai/task-cmd-shift-h-merge-browser-terminal-codepaths

Merge Cmd+Shift+H flash codepath across browser and terminal
This commit is contained in:
Lawrence Chen 2026-02-23 02:39:30 -08:00 committed by GitHub
commit a9603d522c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 125 additions and 36 deletions

View file

@ -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")
}
}
@ -4427,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

View file

@ -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
@ -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) {
@ -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() {

View file

@ -7,6 +7,41 @@ 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 let ringInset: Double = 6
static let ringCornerRadius: Double = 10
static var segments: [FocusFlashSegment] {
let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1)
return (0..<stepCount).map { index in
let startTime = keyTimes[index]
let endTime = keyTimes[index + 1]
return FocusFlashSegment(
delay: startTime * duration,
duration: (endTime - startTime) * duration,
targetOpacity: values[index + 1],
curve: curves[index]
)
}
}
}
/// Protocol for all panel types (terminal, browser, etc.)
@MainActor
public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID {
@ -33,6 +68,9 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
/// Unfocus the panel
func unfocus()
/// Trigger a focus flash animation for this panel.
func triggerFlash()
}
/// Extension providing default implementations

View file

@ -1773,14 +1773,7 @@ final class Workspace: Identifiable, ObservableObject {
// MARK: - Flash/Notification Support
func triggerFocusFlash(panelId: UUID) {
if let terminalPanel = terminalPanel(for: panelId) {
terminalPanel.triggerFlash()
return
}
if let browserPanel = browserPanel(for: panelId) {
browserPanel.triggerFlash()
return
}
panels[panelId]?.triggerFlash()
}
func triggerNotificationFocusFlash(

View file

@ -207,6 +207,42 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
}
final class FocusFlashPatternTests: XCTestCase {
func testFocusFlashPatternMatchesTerminalDoublePulseShape() {
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.ringInset, 6, accuracy: 0.0001)
XCTAssertEqual(FocusFlashPattern.ringCornerRadius, 10, accuracy: 0.0001)
}
func testFocusFlashPatternSegmentsCoverFullDoublePulseTimeline() {
let segments = FocusFlashPattern.segments
XCTAssertEqual(segments.count, 4)
XCTAssertEqual(segments[0].delay, 0.0, accuracy: 0.0001)
XCTAssertEqual(segments[0].duration, 0.225, accuracy: 0.0001)
XCTAssertEqual(segments[0].targetOpacity, 1, accuracy: 0.0001)
XCTAssertEqual(segments[0].curve, .easeOut)
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[2].delay, 0.45, accuracy: 0.0001)
XCTAssertEqual(segments[2].duration, 0.225, accuracy: 0.0001)
XCTAssertEqual(segments[2].targetOpacity, 1, accuracy: 0.0001)
XCTAssertEqual(segments[2].curve, .easeOut)
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)
}
}
@MainActor
final class CmuxWebViewContextMenuTests: XCTestCase {
private func makeRightMouseDownEvent() -> NSEvent {