Created
December 29, 2024 19:16
-
-
Save morajabi/0c852faba59f6af64dfbf5b5d3adc8cd to your computer and use it in GitHub Desktop.
An FPS Counter for macOS apps built for inline.chat
This file contains 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 characters
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) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment