Created
December 29, 2024 19:16
-
-
Save morajabi/0c852faba59f6af64dfbf5b5d3adc8cd to your computer and use it in GitHub Desktop.
Revisions
-
morajabi created this gist
Dec 29, 2024 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,341 @@ import AppKit import Charts import CoreVideo import SwiftUI struct FPSMeasurement: Identifiable, Equatable { let id: Int let fps: Int static func == (lhs: FPSMeasurement, rhs: FPSMeasurement) -> Bool { lhs.id == rhs.id } } class FPSHistory: ObservableObject { @Published var history: [FPSMeasurement] = [] } @available(macOS 14.0, *) class FPSCounter: ObservableObject { @Published private(set) var fps = 0 private(set) var fpsHistory = FPSHistory() private var displayLink: CADisplayLink? static let maxHistoryCount = 20 let maxHistoryCount = FPSCounter.maxHistoryCount private(set) var nextId = 100 private var frameCount = 0 private var lastTimestamp: CFTimeInterval = 0 private let updateInterval: CFTimeInterval = 0.2 private var isTracking = false private weak var trackedWindow: NSWindow? private func fillHistory() { for index in 0...maxHistoryCount { fpsHistory.history.append( FPSMeasurement( id: index, fps: maxFPS ) ) } } func pause() { displayLink?.remove(from: .main, forMode: .common) fpsHistory.history.removeAll() } func resume() { displayLink?.add(to: .main, forMode: .common) } func startTracking(in window: NSWindow) { if isTracking { return } isTracking = true trackedWindow = window displayLink = window.displayLink(target: self, selector: #selector(displayLinkDidFire(_:))) displayLink?.add(to: .main, forMode: .common) } func stopTracking() { displayLink?.invalidate() displayLink = nil trackedWindow = nil isTracking = false } deinit { stopTracking() } @objc private func displayLinkDidFire(_ link: CADisplayLink) { if lastTimestamp == 0 { lastTimestamp = link.timestamp return } frameCount += 1 let elapsed = link.timestamp - lastTimestamp if elapsed >= updateInterval { let currentFPS = Int(round(Double(frameCount) / elapsed)) DispatchQueue.main.async { [weak self] in guard let self else { return } fps = currentFPS withAnimation(.linear(duration: 0.195)) { if fpsHistory.history.isEmpty { fillHistory() return } fpsHistory.history.append(FPSMeasurement(id: nextId, fps: fps)) if fpsHistory.history.count > maxHistoryCount { fpsHistory.history.removeFirst() } } nextId += 1 } frameCount = 0 lastTimestamp = link.timestamp } } var maxFPS: Int { guard let screen = trackedWindow?.screen else { return 60 } return Int(round(1.0 / screen.maximumRefreshInterval)) } } @available(macOS 14.0, *) struct ChartView: View { let paused: Bool @ObservedObject var fps: FPSHistory let maxFPS: Int let barWidth: CGFloat let barSpacing: CGFloat var chartWidth: CGFloat var body: some View { Group { if fps.history.isEmpty || paused { pausedView } else { HStack(alignment: .bottom, spacing: barSpacing) { ForEach(fps.history) { measurement in FPSBar( fps: measurement.fps, maxFPS: maxFPS, width: barWidth ).equatable() } } .frame(width: chartWidth, height: Theme.devtoolsHeight - 4) .contentShape(.interaction, .rect) .padding(.horizontal, 0) .cornerRadius(6) } } .animation(.easeOut(duration: 0.2), value: fps.history.isEmpty) .animation(.easeOut(duration: 0.2), value: paused) } @ViewBuilder var pausedView: some View { Rectangle() .cornerRadius(6) .foregroundStyle(.gray.gradient.quaternary) .overlay { if paused { Text("Paused") .font(.caption) .foregroundColor(.gray) } } .frame(width: chartWidth, height: Theme.devtoolsHeight - 4) } } @available(macOS 14.0, *) struct FPSView: View { @StateObject private var counter = FPSCounter() @State private var isStressing = false @State private var heavyWorkItems: [Int] = [] @State private var paused = false @State private var hasChart = true @ViewBuilder var texts: some View { VStack(alignment: .trailing, spacing: 0) { Text("\(counter.fps) FPS") .foregroundColor(.blue) .font(.system(size: 12, weight: .semibold)) .offset(y: 2) .fixedSize() Text("\(counter.maxFPS)Hz") .foregroundColor(.gray) .font(.caption) .offset(y: -1) } .frame(width: 47, alignment: .trailing) .padding(.trailing, 4) } let barWidth: CGFloat = 2 let barSpacing: CGFloat = 2 var chartWidth: CGFloat { (barWidth + barSpacing) * CGFloat(FPSCounter.maxHistoryCount) } var chart: some View { ChartView( paused: paused, fps: counter.fpsHistory, maxFPS: counter.maxFPS, barWidth: barWidth, barSpacing: barSpacing, chartWidth: chartWidth ) .shakeEffect(isShaking: isStressing) } var body: some View { HStack(alignment: .center, spacing: 0) { texts if hasChart { chart } } .onTapGesture { togglePaused() } .animation(.easeOut(duration: 0.2), value: hasChart) .introspect(.window, on: .macOS(.v13, .v14, .v15)) { window in counter.startTracking(in: window) } .onAppear { paused = false } .padding(.horizontal, 4) .onDisappear { counter.stopTracking() } .contextMenu { Button(!isStressing ? "Enable Stress Test" : "Disable Stress Test") { toggleStressTest() } Button(paused ? "Resume" : "Pause") { togglePaused() } Button(!hasChart ? "Enable Chart View" : "Disable Chart View") { hasChart.toggle() } } } private func togglePaused() { let nextPaused = !paused paused.toggle() if nextPaused { counter.pause() } else { counter.resume() } } private func toggleStressTest() { let nextIsStressing = !isStressing isStressing.toggle() if nextIsStressing { Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in if !isStressing { timer.invalidate() return } for _ in 0...10000 { heavyWorkItems.append(Int.random(in: 0...1000)) _ = sqrt(Double.random(in: 0...10000)) } heavyWorkItems.removeAll() } } } } extension View { func shakeEffect(isShaking: Bool) -> some View { modifier(ShakeEffect(isShaking: isShaking)) } } struct ShakeEffect: ViewModifier { let isShaking: Bool func body(content: Content) -> some View { content .offset( x: isShaking ? CGFloat(Int.random(in: -3...3)) : 0, y: isShaking ? CGFloat(Int.random(in: -1...1)) : 0 ) .animation( isShaking ? .easeIn(duration: 0.09).repeatForever(autoreverses: true) : .default, value: isShaking ) } } @available(macOS 14.0, *) struct FPSBar: View, Equatable { let fps: Int let maxFPS: Int let width: CGFloat private let maxHeight: CGFloat = Theme.devtoolsHeight - 4 private var heightPercentage: CGFloat { guard maxFPS > 0 else { return 0 } return CGFloat(fps) / CGFloat(maxFPS) } var p: Double { Double(fps) / Double(maxFPS) } @ViewBuilder var shape: some View { if p > 0.75 { RoundedRectangle(cornerRadius: 1) .fill(.blue.gradient) } else if p > 0.5 { RoundedRectangle(cornerRadius: 1) .fill(.blue.gradient.secondary) } else { RoundedRectangle(cornerRadius: 1) .fill(.blue.gradient.tertiary) } } var body: some View { shape .frame(width: width, height: maxHeight * heightPercentage) .frame(maxHeight: maxHeight, alignment: .bottom) } }