174 lines
6.0 KiB
Swift
174 lines
6.0 KiB
Swift
import AppKit
|
|
import CmuxFoundation
|
|
import SwiftUI
|
|
|
|
struct TmuxWorkspacePaneOverlayView: View {
|
|
let unreadRects: [CGRect]
|
|
let flashRect: CGRect?
|
|
let activePaneBorderRect: CGRect?
|
|
let activePaneBorderColorHex: String?
|
|
let flashStartedAt: Date?
|
|
let flashReason: WorkspaceAttentionFlashReason?
|
|
let workspaceAttentionColor: WorkspaceAttentionColor
|
|
@State private var completedFlashStartedAt: Date?
|
|
|
|
var body: some View {
|
|
let attentionColor = Color(nsColor: workspaceAttentionColor.nsColor)
|
|
overlayContent(attentionColor: attentionColor)
|
|
.allowsHitTesting(false)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func overlayContent(attentionColor: Color) -> some View {
|
|
if shouldAnimateFlash, let flashStartedAt {
|
|
TimelineView(TmuxWorkspacePaneFlashTimelineSchedule(startDate: flashStartedAt)) { timeline in
|
|
overlayCanvas(timelineDate: timeline.date, attentionColor: attentionColor)
|
|
.onChange(of: timeline.date) { _, date in
|
|
if date.timeIntervalSince(flashStartedAt) >= FocusFlashPattern.duration {
|
|
completedFlashStartedAt = flashStartedAt
|
|
}
|
|
}
|
|
}
|
|
} else if !unreadRects.isEmpty || activePaneBorderRect != nil {
|
|
overlayCanvas(timelineDate: nil, attentionColor: attentionColor)
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
|
|
private var shouldAnimateFlash: Bool {
|
|
guard let flashRect,
|
|
let flashStartedAt else { return false }
|
|
guard completedFlashStartedAt != flashStartedAt,
|
|
ringPath(for: flashRect) != nil else { return false }
|
|
return Date() <= flashStartedAt.addingTimeInterval(FocusFlashPattern.duration)
|
|
}
|
|
|
|
private func overlayCanvas(timelineDate: Date?, attentionColor: Color) -> some View {
|
|
Canvas { context, _ in
|
|
if let activePaneBorderRect,
|
|
let activePaneBorderColorHex {
|
|
drawActivePaneBorder(
|
|
in: &context,
|
|
rect: activePaneBorderRect,
|
|
colorHex: activePaneBorderColorHex
|
|
)
|
|
}
|
|
|
|
for rect in unreadRects {
|
|
drawUnreadRing(in: &context, rect: rect, color: attentionColor)
|
|
}
|
|
|
|
guard let flashRect,
|
|
let flashStartedAt,
|
|
let timelineDate else { return }
|
|
let elapsed = timelineDate.timeIntervalSince(flashStartedAt)
|
|
let opacity = FocusFlashPattern.opacity(at: elapsed)
|
|
guard opacity > 0.001 else { return }
|
|
drawFlashRing(
|
|
in: &context,
|
|
rect: flashRect,
|
|
opacity: opacity,
|
|
reason: flashReason ?? .notificationArrival,
|
|
color: attentionColor
|
|
)
|
|
}
|
|
}
|
|
|
|
private func drawActivePaneBorder(
|
|
in context: inout GraphicsContext,
|
|
rect: CGRect,
|
|
colorHex: String
|
|
) {
|
|
guard let path = ringPath(for: rect),
|
|
let color = NSColor(hex: colorHex) else { return }
|
|
context.stroke(
|
|
path,
|
|
with: .color(Color(nsColor: color)),
|
|
style: StrokeStyle(
|
|
lineWidth: CGFloat(PaneChromeSettings.activeBorderLineWidth),
|
|
lineJoin: .round
|
|
)
|
|
)
|
|
}
|
|
|
|
private func drawUnreadRing(in context: inout GraphicsContext, rect: CGRect, color: Color) {
|
|
guard let path = ringPath(for: rect) else { return }
|
|
let presentation = WorkspaceAttentionCoordinator.notificationRingStyle
|
|
|
|
var glowContext = context
|
|
glowContext.addFilter(
|
|
.shadow(
|
|
color: color.opacity(presentation.glowOpacity),
|
|
radius: presentation.glowRadius
|
|
)
|
|
)
|
|
glowContext.stroke(
|
|
path,
|
|
with: .color(color),
|
|
style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round)
|
|
)
|
|
}
|
|
|
|
private func drawFlashRing(
|
|
in context: inout GraphicsContext,
|
|
rect: CGRect,
|
|
opacity: Double,
|
|
reason: WorkspaceAttentionFlashReason,
|
|
color: Color
|
|
) {
|
|
guard let path = ringPath(for: rect) else { return }
|
|
let presentation = WorkspaceAttentionCoordinator.flashStyle(for: reason)
|
|
|
|
var glowContext = context
|
|
glowContext.addFilter(
|
|
.shadow(
|
|
color: color.opacity(opacity * presentation.glowOpacity),
|
|
radius: presentation.glowRadius
|
|
)
|
|
)
|
|
glowContext.stroke(
|
|
path,
|
|
with: .color(color.opacity(opacity)),
|
|
style: StrokeStyle(lineWidth: PanelOverlayRingMetrics.lineWidth, lineJoin: .round)
|
|
)
|
|
}
|
|
|
|
private func ringPath(for rect: CGRect) -> Path? {
|
|
guard rect.width > PanelOverlayRingMetrics.inset * 2,
|
|
rect.height > PanelOverlayRingMetrics.inset * 2 else { return nil }
|
|
return Path(
|
|
roundedRect: PanelOverlayRingMetrics.pathRect(in: rect),
|
|
cornerRadius: PanelOverlayRingMetrics.cornerRadius
|
|
)
|
|
}
|
|
}
|
|
|
|
struct TmuxWorkspacePaneFlashTimelineSchedule: TimelineSchedule {
|
|
let startDate: Date
|
|
|
|
func entries(from requestedStartDate: Date, mode: Mode) -> Entries {
|
|
let firstDate = requestedStartDate > startDate ? requestedStartDate : startDate
|
|
let interval = mode == .lowFrequency ? 1.0 / 10.0 : 1.0 / 60.0
|
|
return Entries(
|
|
nextDate: firstDate,
|
|
endDate: startDate.addingTimeInterval(FocusFlashPattern.duration),
|
|
interval: interval
|
|
)
|
|
}
|
|
|
|
struct Entries: Sequence, IteratorProtocol {
|
|
var nextDate: Date
|
|
let endDate: Date
|
|
let interval: TimeInterval
|
|
|
|
mutating func next() -> Date? {
|
|
guard nextDate <= endDate else { return nil }
|
|
let date = nextDate
|
|
nextDate = nextDate.addingTimeInterval(interval)
|
|
return date
|
|
}
|
|
}
|
|
}
|