← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


commit cdabb255639aa9fe539cfedef586273318354820
parent 18c4f2203f1c888ad2861a9d00366075d271f90d
Author: Michael Camilleri <[email protected]>
Date:   Tue, 21 Apr 2026 23:52:11 +0900

Add performance logging

Diffstat

MListless.xcodeproj/project.pbxproj | 8++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme | 22+++++++---------------
MListless.xcodeproj/xcshareddata/xcschemes/Listless watchOS.xcscheme | 25++++++++-----------------
AListlessiOS/Helpers/PerfSampler.swift | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Helpers/TappableTextField.swift | 76+++++++++++++++++++++++++++++++++++++++++++++-------------------------------
AListlessiOS/Views/PerfDebugView.swift | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/SettingsView.swift | 3+++

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()