commit cdabb255639aa9fe539cfedef586273318354820
parent 18c4f2203f1c888ad2861a9d00366075d271f90d
Author: Michael Camilleri <[email protected]>
Date: Tue, 21 Apr 2026 23:52:11 +0900
Add performance logging
Diffstat:
7 files changed, 397 insertions(+), 63 deletions(-)
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -61,6 +61,7 @@
6FE9D247153209BD4CFD9E34 /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; };
73C05B273DC49DE48B82822E /* ItemValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EB9F584EF43678536F5FDE /* ItemValue.swift */; };
763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; };
+ 7770D06CBDE3E87B7FDE7C21 /* PerfDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10806B0AD984F8E480019FD /* PerfDebugView.swift */; };
77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; };
785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567DBAC2A39FA2760D006AAB /* PullToClear.swift */; };
790843E40F28B4E186F88F16 /* ItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8EC97702E7B218A03B7898 /* ItemStore.swift */; };
@@ -70,6 +71,7 @@
851F46417FE40D6BC765BC70 /* ItemListView+Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F2C024B6C1F2F1FD7A25B0 /* ItemListView+Logic.swift */; };
882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; };
889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; };
+ 88C2303FCAD99AC5D1D1C81C /* PerfSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 361665491B2313B7A06A94B9 /* PerfSampler.swift */; };
89C4109374BD64464B0018B7 /* ItemListView+NavigationHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CC030045617DBC90812D79 /* ItemListView+NavigationHeader.swift */; };
8FB18395E5436F6C91A0F077 /* ItemRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7CB071709951F52C7742A3 /* ItemRowView.swift */; };
9082E96001188E516B7F903B /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3116A37F1353BF6E18308DD2 /* ItemListView.swift */; };
@@ -200,6 +202,7 @@
3048ACA1CAF1284F99E1400E /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = "<group>"; };
3116A37F1353BF6E18308DD2 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
+ 361665491B2313B7A06A94B9 /* PerfSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfSampler.swift; sourceTree = "<group>"; };
37F80F7BB632A04A687890F0 /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
3C96B8A403274C4CC7F57460 /* AccentColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccentColorTests.swift; sourceTree = "<group>"; };
3DD31E8962F7EEC22EFC0CA9 /* Credits.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = Credits.html; sourceTree = "<group>"; };
@@ -257,6 +260,7 @@
C9B14DC786A336008AAB78EE /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; };
CCB5F87A520B1CD47F2F71D0 /* Listless macOS UI Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Listless macOS UI Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
CF3374CE58E7D9378C6997D2 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
+ D10806B0AD984F8E480019FD /* PerfDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfDebugView.swift; sourceTree = "<group>"; };
D3E995954787F0A14CCFF348 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
D43D37CE25806380C0B13466 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; };
D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; };
@@ -293,6 +297,7 @@
E71FBC3E19D32F527B5FE9E6 /* ItemRowMetrics.swift */,
DCCE85438FE23E43095B2C25 /* ItemRowSwipeGesture.swift */,
E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */,
+ 361665491B2313B7A06A94B9 /* PerfSampler.swift */,
FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */,
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */,
6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */,
@@ -381,6 +386,7 @@
67CB2A88C7F19F8305EBED43 /* DraftRowView.swift */,
3116A37F1353BF6E18308DD2 /* ItemListView.swift */,
C0DF205300C6B51A53B256D6 /* ItemRowView.swift */,
+ D10806B0AD984F8E480019FD /* PerfDebugView.swift */,
567DBAC2A39FA2760D006AAB /* PullToClear.swift */,
BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */,
C611E04943F1D82D6F975592 /* SettingsView.swift */,
@@ -913,6 +919,8 @@
F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */,
5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,
F0B2B806BD84A4F2FDF8E038 /* ListlessiOSApp.swift in Sources */,
+ 7770D06CBDE3E87B7FDE7C21 /* PerfDebugView.swift in Sources */,
+ 88C2303FCAD99AC5D1D1C81C /* PerfSampler.swift in Sources */,
DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */,
763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */,
77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */,
diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme
@@ -4,8 +4,7 @@
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
- buildImplicitDependencies = "YES"
- runPostActionsOnFailure = "NO">
+ buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
@@ -16,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB"
- BuildableName = "Listless macOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless macOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
@@ -34,7 +33,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB"
- BuildableName = "Listless macOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless macOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
@@ -45,13 +44,12 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES"
- onlyGenerateCoverageForSpecifiedTargets = "NO">
+ shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB"
- BuildableName = "Listless macOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless macOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
@@ -82,8 +80,6 @@
</BuildableReference>
</TestableReference>
</Testables>
- <CommandLineArguments>
- </CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -100,13 +96,11 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB"
- BuildableName = "Listless macOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless macOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
- <CommandLineArguments>
- </CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -119,13 +113,11 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "0FB4F07A37999BBC6DFE4DBB"
- BuildableName = "Listless macOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless macOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
- <CommandLineArguments>
- </CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme
@@ -4,8 +4,7 @@
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
- buildImplicitDependencies = "YES"
- runPostActionsOnFailure = "NO">
+ buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
@@ -16,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9BDC1B2175AB9CE26790448D"
- BuildableName = "Listless watchOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless watchOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
@@ -34,7 +33,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9BDC1B2175AB9CE26790448D"
- BuildableName = "Listless watchOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless watchOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
@@ -45,21 +44,19 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES"
- onlyGenerateCoverageForSpecifiedTargets = "NO">
+ shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9BDC1B2175AB9CE26790448D"
- BuildableName = "Listless watchOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless watchOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
- skipped = "NO"
- parallelizable = "NO">
+ skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAC694EFB69F0D9F12557137"
@@ -69,8 +66,6 @@
</BuildableReference>
</TestableReference>
</Testables>
- <CommandLineArguments>
- </CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -87,13 +82,11 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9BDC1B2175AB9CE26790448D"
- BuildableName = "Listless watchOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless watchOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
- <CommandLineArguments>
- </CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -106,13 +99,11 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "9BDC1B2175AB9CE26790448D"
- BuildableName = "Listless watchOS.app"
+ BuildableName = "Listless.app"
BlueprintName = "Listless watchOS"
ReferencedContainer = "container:Listless.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
- <CommandLineArguments>
- </CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
diff --git a/ListlessiOS/Helpers/PerfSampler.swift b/ListlessiOS/Helpers/PerfSampler.swift
@@ -0,0 +1,144 @@
+import Foundation
+import UIKit
+
+/// Lightweight timing collector for diagnosing cold-launch hitches without
+/// needing Instruments. Samples live in memory during a launch and are flushed
+/// to disk when the app backgrounds, so the next launch can display prior data
+/// from the in-app debug screen. Call indexes restart at 0 per launch so the
+/// first-invocation cost of each label is easy to spot.
+@MainActor
+final class PerfSampler {
+ static let shared = PerfSampler()
+
+ struct Sample: Codable, Identifiable {
+ var id = UUID()
+ let launchID: UUID
+ let label: String
+ let callIndex: Int
+ let durationMs: Double
+ let msSinceLaunch: Double
+ let timestamp: Date
+ }
+
+ struct Launch: Codable, Identifiable {
+ var id: UUID { launchID }
+ let launchID: UUID
+ let startedAt: Date
+ }
+
+ private let storageURL: URL
+ private let maxTotalSamples = 1000
+ private let maxPerLabelPerLaunch = 40
+
+ private(set) var currentLaunch: Launch
+ private var launches: [Launch] = []
+ private var samples: [Sample] = []
+ private var callCounts: [String: Int] = [:]
+ private let launchClockStart: DispatchTime
+ private var dirty = false
+
+ private init() {
+ let dir = try? FileManager.default.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ )
+ storageURL = (dir ?? URL(fileURLWithPath: NSTemporaryDirectory()))
+ .appendingPathComponent("PerfSamples.json")
+
+ let launch = Launch(launchID: UUID(), startedAt: Date())
+ currentLaunch = launch
+ launchClockStart = DispatchTime.now()
+ load()
+ launches.append(launch)
+ dirty = true
+
+ NotificationCenter.default.addObserver(
+ forName: UIApplication.didEnterBackgroundNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ Task { @MainActor in PerfSampler.shared.flush() }
+ }
+ NotificationCenter.default.addObserver(
+ forName: UIApplication.willTerminateNotification,
+ object: nil,
+ queue: .main
+ ) { _ in
+ Task { @MainActor in PerfSampler.shared.flush() }
+ }
+ }
+
+ @discardableResult
+ func measure<T>(_ label: String, _ work: () -> T) -> T {
+ let start = DispatchTime.now()
+ let result = work()
+ let elapsedNs = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
+ record(label: label, durationMs: Double(elapsedNs) / 1_000_000)
+ return result
+ }
+
+ func record(label: String, durationMs: Double) {
+ let index = callCounts[label, default: 0]
+ callCounts[label] = index + 1
+ guard index < maxPerLabelPerLaunch else { return }
+
+ let sinceLaunchNs = DispatchTime.now().uptimeNanoseconds
+ &- launchClockStart.uptimeNanoseconds
+ let sample = Sample(
+ launchID: currentLaunch.launchID,
+ label: label,
+ callIndex: index,
+ durationMs: durationMs,
+ msSinceLaunch: Double(sinceLaunchNs) / 1_000_000,
+ timestamp: Date()
+ )
+ samples.append(sample)
+ if samples.count > maxTotalSamples {
+ samples.removeFirst(samples.count - maxTotalSamples)
+ }
+ dirty = true
+ }
+
+ func allSamples() -> [Sample] { samples }
+ func allLaunches() -> [Launch] { launches }
+
+ func samplesForCurrentLaunch() -> [Sample] {
+ samples.filter { $0.launchID == currentLaunch.launchID }
+ }
+
+ func clear() {
+ samples.removeAll()
+ launches = [currentLaunch]
+ callCounts.removeAll()
+ dirty = true
+ flush()
+ }
+
+ func flush() {
+ guard dirty else { return }
+ let payload = StoredPayload(launches: launches, samples: samples)
+ do {
+ let data = try JSONEncoder().encode(payload)
+ try data.write(to: storageURL, options: .atomic)
+ dirty = false
+ } catch {
+ // Best-effort: swallow errors; debug data is non-critical.
+ }
+ }
+
+ private func load() {
+ guard let data = try? Data(contentsOf: storageURL),
+ let payload = try? JSONDecoder().decode(StoredPayload.self, from: data)
+ else { return }
+ launches = payload.launches.suffix(20)
+ let keepIDs = Set(launches.map(\.launchID))
+ samples = payload.samples.filter { keepIDs.contains($0.launchID) }
+ }
+
+ private struct StoredPayload: Codable {
+ let launches: [Launch]
+ let samples: [Sample]
+ }
+}
diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift
@@ -15,35 +15,43 @@ struct TappableTextField: UIViewRepresentable {
var initialCursorPoint: CGPoint? = nil
func makeUIView(context: Context) -> UITextView {
- let textView = UITextView()
- textView.accessibilityIdentifier = uiAccessibilityIdentifier
- textView.delegate = context.coordinator
- textView.font = ItemRowMetrics.bodyUIK
- textView.backgroundColor = .clear
- textView.textContainerInset = .zero
- textView.textContainer.lineFragmentPadding = 0
- textView.isScrollEnabled = false
- textView.autocorrectionType = .default
- textView.autocapitalizationType = .sentences
- textView.returnKeyType = returnKeyType
-
- let placeholder = UILabel()
- placeholder.text = "Enter text"
- placeholder.font = ItemRowMetrics.bodyUIK
- placeholder.textColor = .placeholderText
- placeholder.tag = 100
- placeholder.translatesAutoresizingMaskIntoConstraints = false
- textView.addSubview(placeholder)
- NSLayoutConstraint.activate([
- placeholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
- placeholder.topAnchor.constraint(equalTo: textView.topAnchor),
- ])
-
- context.coordinator.textView = textView
- return textView
+ PerfSampler.shared.measure("TappableTextField.makeUIView") {
+ let textView = UITextView()
+ textView.accessibilityIdentifier = uiAccessibilityIdentifier
+ textView.delegate = context.coordinator
+ textView.font = ItemRowMetrics.bodyUIK
+ textView.backgroundColor = .clear
+ textView.textContainerInset = .zero
+ textView.textContainer.lineFragmentPadding = 0
+ textView.isScrollEnabled = false
+ textView.autocorrectionType = .default
+ textView.autocapitalizationType = .sentences
+ textView.returnKeyType = returnKeyType
+
+ let placeholder = UILabel()
+ placeholder.text = "Enter text"
+ placeholder.font = ItemRowMetrics.bodyUIK
+ placeholder.textColor = .placeholderText
+ placeholder.tag = 100
+ placeholder.translatesAutoresizingMaskIntoConstraints = false
+ textView.addSubview(placeholder)
+ NSLayoutConstraint.activate([
+ placeholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
+ placeholder.topAnchor.constraint(equalTo: textView.topAnchor),
+ ])
+
+ context.coordinator.textView = textView
+ return textView
+ }
}
func updateUIView(_ textView: UITextView, context: Context) {
+ PerfSampler.shared.measure("TappableTextField.updateUIView") {
+ updateUIViewBody(textView, context: context)
+ }
+ }
+
+ private func updateUIViewBody(_ textView: UITextView, context: Context) {
if !textView.isFirstResponder {
applyStyle(to: textView, text: text, isCompleted: isCompleted)
} else if text.isEmpty && !textView.text.isEmpty {
@@ -81,11 +89,13 @@ struct TappableTextField: UIViewRepresentable {
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
- let proposedWidth = proposal.width ?? uiView.bounds.width
- let width = proposedWidth > 0 ? proposedWidth : (uiView.window?.bounds.width ?? 0)
- guard width > 0 else { return nil }
- let fitted = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
- return CGSize(width: width, height: fitted.height)
+ PerfSampler.shared.measure("TappableTextField.sizeThatFits") {
+ let proposedWidth = proposal.width ?? uiView.bounds.width
+ let width = proposedWidth > 0 ? proposedWidth : (uiView.window?.bounds.width ?? 0)
+ guard width > 0 else { return nil }
+ let fitted = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
+ return CGSize(width: width, height: fitted.height)
+ }
}
func makeCoordinator() -> Coordinator {
@@ -149,6 +159,10 @@ struct TappableTextField: UIViewRepresentable {
}
func textViewDidBeginEditing(_ textView: UITextView) {
+ PerfSampler.shared.record(
+ label: "TappableTextField.didBeginEditing",
+ durationMs: 0
+ )
if let point = initialCursorPoint {
initialCursorPoint = nil
textView.layoutIfNeeded()
diff --git a/ListlessiOS/Views/PerfDebugView.swift b/ListlessiOS/Views/PerfDebugView.swift
@@ -0,0 +1,182 @@
+import SwiftUI
+import UIKit
+
+struct PerfDebugView: View {
+ @State private var refreshTick = 0
+
+ var body: some View {
+ List {
+ Section("Summary (current launch)") {
+ let stats = perLabelStats(for: PerfSampler.shared.samplesForCurrentLaunch())
+ if stats.isEmpty {
+ Text("No samples yet. Interact with the app and return here.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(stats, id: \.label) { stat in
+ StatRow(stat: stat)
+ }
+ }
+ }
+
+ ForEach(launchesMostRecentFirst()) { launch in
+ Section(sectionTitle(for: launch)) {
+ let launchSamples = samples(for: launch.launchID)
+ if launchSamples.isEmpty {
+ Text("No samples recorded.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(launchSamples) { sample in
+ SampleRow(sample: sample)
+ }
+ }
+ }
+ }
+ }
+ .id(refreshTick)
+ .navigationTitle("Perf Samples")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Menu {
+ Button("Refresh") { refreshTick &+= 1 }
+ Button("Copy Summary") { copySummary() }
+ Button("Clear All", role: .destructive) {
+ PerfSampler.shared.clear()
+ refreshTick &+= 1
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ }
+ }
+
+ private func launchesMostRecentFirst() -> [PerfSampler.Launch] {
+ PerfSampler.shared.allLaunches().sorted { $0.startedAt > $1.startedAt }
+ }
+
+ private func samples(for launchID: UUID) -> [PerfSampler.Sample] {
+ PerfSampler.shared.allSamples()
+ .filter { $0.launchID == launchID }
+ .sorted { $0.msSinceLaunch < $1.msSinceLaunch }
+ }
+
+ private func sectionTitle(for launch: PerfSampler.Launch) -> String {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMM d, HH:mm:ss"
+ let isCurrent = launch.launchID == PerfSampler.shared.currentLaunch.launchID
+ return formatter.string(from: launch.startedAt) + (isCurrent ? " (current)" : "")
+ }
+
+ private func perLabelStats(for samples: [PerfSampler.Sample]) -> [LabelStat] {
+ let grouped = Dictionary(grouping: samples, by: \.label)
+ return grouped.map { label, entries in
+ let sorted = entries.sorted(by: { $0.callIndex < $1.callIndex })
+ let durations = sorted.map(\.durationMs)
+ return LabelStat(
+ label: label,
+ count: sorted.count,
+ firstCallMs: durations.first ?? 0,
+ medianMs: median(durations),
+ maxMs: durations.max() ?? 0,
+ firstCallMsSinceLaunch: sorted.first?.msSinceLaunch ?? 0
+ )
+ }
+ .sorted { $0.firstCallMs > $1.firstCallMs }
+ }
+
+ private func median(_ values: [Double]) -> Double {
+ guard !values.isEmpty else { return 0 }
+ let sorted = values.sorted()
+ let mid = sorted.count / 2
+ if sorted.count % 2 == 0 {
+ return (sorted[mid - 1] + sorted[mid]) / 2
+ }
+ return sorted[mid]
+ }
+
+ private func copySummary() {
+ var lines: [String] = []
+ for launch in launchesMostRecentFirst() {
+ lines.append("=== Launch \(launch.startedAt) ===")
+ let stats = perLabelStats(for: samples(for: launch.launchID))
+ for stat in stats {
+ lines.append(String(
+ format: "%@ n=%d first=%.2fms median=%.2fms max=%.2fms firstAt=%.0fms",
+ stat.label,
+ stat.count,
+ stat.firstCallMs,
+ stat.medianMs,
+ stat.maxMs,
+ stat.firstCallMsSinceLaunch
+ ))
+ }
+ lines.append("")
+ }
+ UIPasteboard.general.string = lines.joined(separator: "\n")
+ }
+}
+
+private struct LabelStat {
+ let label: String
+ let count: Int
+ let firstCallMs: Double
+ let medianMs: Double
+ let maxMs: Double
+ let firstCallMsSinceLaunch: Double
+}
+
+private struct StatRow: View {
+ let stat: LabelStat
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(stat.label)
+ .font(.callout.monospaced())
+ HStack(spacing: 12) {
+ Text("n=\(stat.count)")
+ Text(String(format: "1st=%.1fms", stat.firstCallMs))
+ .foregroundStyle(stat.firstCallMs > 16 ? .red : .primary)
+ Text(String(format: "med=%.1f", stat.medianMs))
+ Text(String(format: "max=%.1f", stat.maxMs))
+ }
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ Text(String(format: "first call at %.0f ms since launch", stat.firstCallMsSinceLaunch))
+ .font(.caption2.monospaced())
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct SampleRow: View {
+ let sample: PerfSampler.Sample
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Text(String(format: "%5.0fms", sample.msSinceLaunch))
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ Text("#\(sample.callIndex)")
+ .font(.caption.monospaced())
+ .foregroundStyle(.tertiary)
+ .frame(width: 32, alignment: .leading)
+ Text(shortLabel(sample.label))
+ .font(.caption.monospaced())
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Spacer(minLength: 4)
+ Text(String(format: "%.2f ms", sample.durationMs))
+ .font(.caption.monospaced())
+ .foregroundStyle(sample.durationMs > 16 ? .red : .primary)
+ }
+ }
+
+ private func shortLabel(_ label: String) -> String {
+ if let range = label.range(of: ".") {
+ return String(label[range.upperBound...])
+ }
+ return label
+ }
+}
diff --git a/ListlessiOS/Views/SettingsView.swift b/ListlessiOS/Views/SettingsView.swift
@@ -65,6 +65,9 @@ struct SettingsView: View {
NavigationLink("iCloud Diagnostics") {
SyncDiagnosticsView(syncMonitor: syncMonitor)
}
+ NavigationLink("Perf Samples") {
+ PerfDebugView()
+ }
Button("Reset Tutorial") {
didCompleteTutorial = false
dismiss()