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:
commit
a9603d522c
5 changed files with 125 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue