From 1a4f788992bdb3a10cb982167605b4ce4c13fc5a Mon Sep 17 00:00:00 2001 From: HashaamKhan19 Date: Thu, 16 Jul 2026 00:21:32 +0500 Subject: [PATCH] Add pause/resume for the runner animation Adds a "Pause Animation" / "Resume Animation" item to the dropdown menu that freezes the running cat in place (preserving its position via the existing CALayer speed/timeOffset plumbing) without quitting the app. The paused state is persisted in UserDefaults and broadcast through AppState so RunnerBar (which owns the CALayer) and Dashboard (which owns the menu) stay in sync. Signed-off-by: HashaamKhan19 Co-authored-by: Cursor --- .../DataSource/Entities/AppState.swift | 1 + .../Extensions/String+Extension.swift | 1 + .../Repositories/UserDefaultsRepository.swift | 6 + .../Model/Services/RunnerService.swift | 6 + .../Sources/Model/Stores/Dashboard.swift | 10 ++ .../Sources/Model/Stores/RunnerBar.swift | 15 +- .../Resources/Localizable.xcstrings | 132 ++++++++++++++++++ .../RunnerBar/Dashboard/DashboardView.swift | 1 + .../Views/RunnerBar/Dashboard/MenuView.swift | 20 +++ .../Views/RunnerBar/RunnerBarView.swift | 3 +- .../Views/RunnerBar/RunnerLayer.swift | 15 ++ .../ServiceTests/RunnerServiceTests.swift | 31 ++++ .../StoreTests/DashboardTests.swift | 23 +++ .../StoreTests/RunnerBarTests.swift | 15 ++ 14 files changed, 277 insertions(+), 2 deletions(-) diff --git a/LocalPackage/Sources/DataSource/Entities/AppState.swift b/LocalPackage/Sources/DataSource/Entities/AppState.swift index bb1bf8f..df23f20 100644 --- a/LocalPackage/Sources/DataSource/Entities/AppState.swift +++ b/LocalPackage/Sources/DataSource/Entities/AppState.swift @@ -33,6 +33,7 @@ public struct AppState: Sendable { public var runnerBundleLists = AsyncStreamBundle<[RunnerBundle]>() public var runnerBundles = AsyncStreamBundle() public var runnerSpeeds = AsyncStreamBundle() + public var runnerPauses = AsyncStreamBundle() init( name: String = "", diff --git a/LocalPackage/Sources/DataSource/Extensions/String+Extension.swift b/LocalPackage/Sources/DataSource/Extensions/String+Extension.swift index 1b3b01f..b7cf39f 100644 --- a/LocalPackage/Sources/DataSource/Extensions/String+Extension.swift +++ b/LocalPackage/Sources/DataSource/Extensions/String+Extension.swift @@ -22,6 +22,7 @@ extension String { static let customMetricsConfiguration = "CUSTOM_METRICS_CONFIGURATION" static let customRunners = "CUSTOM_RUNNERS" static let isFlippedHorizontally = "IS_FLIPPED_HORIZONTALLY" + static let isRunnerPaused = "IS_RUNNER_PAUSED" static let metricsBarConfiguration = "METRICS_BAR_CONFIGURATION" static let runnerID = "RUNNER_ID" static let speedDecreasesUnderLoad = "SPEED_DECREASES_UNDER_LOAD" diff --git a/LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift b/LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift index 380c37b..fd2db9b 100644 --- a/LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift +++ b/LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift @@ -38,6 +38,11 @@ public struct UserDefaultsRepository: Sendable { nonmutating set { userDefaultsClient.set(newValue, .isFlippedHorizontally) } } + public var isRunnerPaused: Bool { + get { userDefaultsClient.bool(.isRunnerPaused) } + nonmutating set { userDefaultsClient.set(newValue, .isRunnerPaused) } + } + public var updateInterval: UpdateInterval { get { UpdateInterval(rawValue: userDefaultsClient.integer(.updateInterval)) ?? .default } nonmutating set { userDefaultsClient.set(newValue.rawValue, .updateInterval) } @@ -108,6 +113,7 @@ public struct UserDefaultsRepository: Sendable { .runnerID: RunnerKind.cat.id, .speedDecreasesUnderLoad: false, .isFlippedHorizontally: false, + .isRunnerPaused: false, .updateInterval: UpdateInterval.default.rawValue, ]) if ProcessInfo.needsShowAllData { diff --git a/LocalPackage/Sources/Model/Services/RunnerService.swift b/LocalPackage/Sources/Model/Services/RunnerService.swift index 4b32652..898dcab 100644 --- a/LocalPackage/Sources/Model/Services/RunnerService.swift +++ b/LocalPackage/Sources/Model/Services/RunnerService.swift @@ -84,6 +84,12 @@ struct RunnerService { try update(runner: .default) } loadRunnerBundleList() + appStateClient.send(\.runnerPauses, userDefaultsRepository.isRunnerPaused) + } + + func updateRunnerPaused(_ isPaused: Bool) { + userDefaultsRepository.isRunnerPaused = isPaused + appStateClient.send(\.runnerPauses, isPaused) } func updateRunnerSpeed(from cpuInfo: CPUInfo?) { diff --git a/LocalPackage/Sources/Model/Stores/Dashboard.swift b/LocalPackage/Sources/Model/Stores/Dashboard.swift index 1793ac8..9d7211e 100644 --- a/LocalPackage/Sources/Model/Stores/Dashboard.swift +++ b/LocalPackage/Sources/Model/Stores/Dashboard.swift @@ -30,6 +30,7 @@ public final class Dashboard: Composable { private let nsWorkspaceClient: NSWorkspaceClient private let userDefaultsRepository: UserDefaultsRepository private let logService: LogService + private let runnerService: RunnerService @ObservationIgnored private var task: Task? @@ -38,6 +39,7 @@ public final class Dashboard: Composable { public var cpuRingBuffer: RingBuffer public var memoryRingBuffer: RingBuffer public var customMetricsBundles: [CustomMetricsBundle] + public var isPaused: Bool public let isPreview: Bool public let action: (Action) async -> Void @@ -48,6 +50,7 @@ public final class Dashboard: Composable { cpuRingBuffer: RingBuffer = .init(), memoryRingBuffer: RingBuffer = .init(), customMetricsBundles: [CustomMetricsBundle] = [], + isPaused: Bool? = nil, isPreview: Bool? = nil, action: @escaping (Action) async -> Void = { _ in } ) { @@ -56,11 +59,13 @@ public final class Dashboard: Composable { self.nsWorkspaceClient = appDependencies.nsWorkspaceClient self.userDefaultsRepository = .init(appDependencies.userDefaultsClient) self.logService = .init(appDependencies) + self.runnerService = .init(appDependencies) self.appName = appName ?? appStateClient.withLock(\.name) self.systemInfoBundle = systemInfoBundle self.cpuRingBuffer = cpuRingBuffer self.memoryRingBuffer = memoryRingBuffer self.customMetricsBundles = customMetricsBundles + self.isPaused = isPaused ?? userDefaultsRepository.isRunnerPaused self.isPreview = isPreview ?? ProcessInfo.isPreview self.action = action } @@ -104,6 +109,10 @@ public final class Dashboard: Composable { case .reportIssueButtonTapped: _ = nsWorkspaceClient.open(URL.githubIssues) + case .pauseButtonTapped: + isPaused.toggle() + runnerService.updateRunnerPaused(isPaused) + case .quitButtonTapped: nsAppClient.terminate(nil) @@ -130,6 +139,7 @@ public final class Dashboard: Composable { case aboutButtonTapped(AttributedString) case openSourceLicenseButtonTapped(OpenWindowActionWrapper) case reportIssueButtonTapped + case pauseButtonTapped case quitButtonTapped case debugSleepButtonTapped case debugWakeUpButtonTapped diff --git a/LocalPackage/Sources/Model/Stores/RunnerBar.swift b/LocalPackage/Sources/Model/Stores/RunnerBar.swift index d123519..c16009c 100644 --- a/LocalPackage/Sources/Model/Stores/RunnerBar.swift +++ b/LocalPackage/Sources/Model/Stores/RunnerBar.swift @@ -82,6 +82,12 @@ public final class RunnerBar: Composable { self?.update(runnerSpeed: value) } } + group.addImmediateTask { + let stream = appStateClient.withLock(\.runnerPauses.stream) + for await value in stream { + self?.update(isPaused: value) + } + } } } @@ -134,6 +140,10 @@ public final class RunnerBar: Composable { eventBridge?.setSpeed(runnerSpeed) } + private func update(isPaused: Bool) { + eventBridge?.setPaused(isPaused) + } + public enum Action: Sendable { case task(String, EventBridge) case onDisappear @@ -144,19 +154,22 @@ public final class RunnerBar: Composable { public var setFrames: @MainActor @Sendable ([NSImage], Bool) -> Void public var setColor: @MainActor @Sendable (CGColor, Bool) -> Void public var setSpeed: @MainActor @Sendable (Float) -> Void + public var setPaused: @MainActor @Sendable (Bool) -> Void public init( getBundleImage: @escaping @MainActor @Sendable (String) -> NSImage, setSize: @escaping @MainActor @Sendable (CGSize) -> Void, setFrames: @escaping @MainActor @Sendable ([NSImage], Bool) -> Void, setColor: @escaping @MainActor @Sendable (CGColor, Bool) -> Void, - setSpeed: @escaping @MainActor @Sendable (Float) -> Void + setSpeed: @escaping @MainActor @Sendable (Float) -> Void, + setPaused: @escaping @MainActor @Sendable (Bool) -> Void ) { self.getBundleImage = getBundleImage self.setSize = setSize self.setFrames = setFrames self.setColor = setColor self.setSpeed = setSpeed + self.setPaused = setPaused } } } diff --git a/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings b/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings index fd4aee3..faf79e0 100644 --- a/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings +++ b/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings @@ -3565,6 +3565,72 @@ } } }, + "pauseAnimation" : { + "comment" : "A label for the \"Pause Animation\" menu item.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animation pausieren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause Animation" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pausar animación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspendre l’animation" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アニメーションを一時停止" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "애니메이션 일시정지" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Приостановить анимацию" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tạm dừng hoạt ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂停动画" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫停動畫" + } + } + } + }, "preview" : { "comment" : "A label for a preview image.", "isCommentAutoGenerated" : true, @@ -4159,6 +4225,72 @@ } } }, + "resumeAnimation" : { + "comment" : "A label for the \"Resume Animation\" menu item.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Animation fortsetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume Animation" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reanudar animación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reprendre l’animation" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アニメーションを再開" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "애니메이션 재생" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить анимацию" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiếp tục hoạt ảnh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续动画" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "繼續動畫" + } + } + } + }, "runnerGalleryDescription" : { "comment" : "A link to the runner gallery.", "isCommentAutoGenerated" : true, diff --git a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/DashboardView.swift b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/DashboardView.swift index 6132c62..13c8e9d 100644 --- a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/DashboardView.swift +++ b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/DashboardView.swift @@ -35,6 +35,7 @@ struct DashboardView: View { MenuView( appName: store.appName, isPreview: store.isPreview, + isPaused: store.isPaused, buttonTapped: { action in await store.send(action) } diff --git a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/MenuView.swift b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/MenuView.swift index 9287f47..7f4c75a 100644 --- a/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/MenuView.swift +++ b/LocalPackage/Sources/UserInterface/Views/RunnerBar/Dashboard/MenuView.swift @@ -26,6 +26,7 @@ struct MenuView: View { @Environment(\.openWindow) private var openWindow var appName: String var isPreview: Bool + var isPaused: Bool var buttonTapped: (Dashboard.Action) async -> Void private var aboutBody: AttributedString { @@ -67,6 +68,25 @@ struct MenuView: View { Image(.activityMonitor) } } + Button { + Task { + await buttonTapped(.pauseButtonTapped) + } + } label: { + if isPaused { + Label { + Text("resumeAnimation", bundle: .module) + } icon: { + Image(systemName: "play.fill") + } + } else { + Label { + Text("pauseAnimation", bundle: .module) + } icon: { + Image(systemName: "pause.fill") + } + } + } Divider() Button { Task { diff --git a/LocalPackage/Sources/UserInterface/Views/RunnerBar/RunnerBarView.swift b/LocalPackage/Sources/UserInterface/Views/RunnerBar/RunnerBarView.swift index 64d709a..98ec2cc 100644 --- a/LocalPackage/Sources/UserInterface/Views/RunnerBar/RunnerBarView.swift +++ b/LocalPackage/Sources/UserInterface/Views/RunnerBar/RunnerBarView.swift @@ -65,7 +65,8 @@ struct RunnerBarView: View { setSize: { backgroundLayer.setSize($0) }, setFrames: { runnerLayer.setFrames($0, $1) }, setColor: { runnerLayer.setColor($0, $1) }, - setSpeed: { runnerLayer.setSpeed($0) } + setSpeed: { runnerLayer.setSpeed($0) }, + setPaused: { runnerLayer.setPaused($0) } ) )) } diff --git a/LocalPackage/Sources/UserInterface/Views/RunnerBar/RunnerLayer.swift b/LocalPackage/Sources/UserInterface/Views/RunnerBar/RunnerLayer.swift index f4b7c0f..e64227e 100644 --- a/LocalPackage/Sources/UserInterface/Views/RunnerBar/RunnerLayer.swift +++ b/LocalPackage/Sources/UserInterface/Views/RunnerBar/RunnerLayer.swift @@ -25,6 +25,8 @@ final class RunnerLayer: CALayer { private var width = CGFloat.zero private let maskLayer = CALayer() private var keyFrameAnimation = CAKeyframeAnimation(keyPath: "contents") + private var lastSpeed = Float(1.0) + private var isPaused = false init(gap: CGFloat) { self.gap = gap @@ -69,6 +71,7 @@ final class RunnerLayer: CALayer { add(keyFrameAnimation, forKey: "running") } CATransaction.commit() + applySpeed(isPaused ? .zero : lastSpeed) } func setColor(_ tintColor: CGColor, _ isTemplate: Bool) { @@ -79,6 +82,18 @@ final class RunnerLayer: CALayer { } func setSpeed(_ speed: Float) { + lastSpeed = speed + guard !isPaused else { return } + applySpeed(speed) + } + + func setPaused(_ paused: Bool) { + guard paused != isPaused else { return } + isPaused = paused + applySpeed(paused ? .zero : lastSpeed) + } + + private func applySpeed(_ speed: Float) { CATransaction.begin() CATransaction.setDisableActions(true) if mask == nil { diff --git a/LocalPackage/Tests/ModelTests/ServiceTests/RunnerServiceTests.swift b/LocalPackage/Tests/ModelTests/ServiceTests/RunnerServiceTests.swift index a19861d..33e68cf 100644 --- a/LocalPackage/Tests/ModelTests/ServiceTests/RunnerServiceTests.swift +++ b/LocalPackage/Tests/ModelTests/ServiceTests/RunnerServiceTests.swift @@ -227,6 +227,37 @@ struct RunnerServiceTests { #expect(appState.withLock(\.runnerBundles.latestValue)?.runner == Runner.default) } + @Test + func setup_sends_persisted_paused_state() throws { + let appState = AllocatedUnfairLock(initialState: .init()) + let sut = RunnerService(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: testDependency(of: UserDefaultsClient.self) { + $0.bool = { _ in true } + } + )) + try sut.setup() + #expect(appState.withLock(\.runnerPauses.latestValue) == true) + } + + @Test + func updateRunnerPaused_stores_and_sends_paused_state() { + let appState = AllocatedUnfairLock(initialState: .init()) + let setCallStack = AllocatedUnfairLock<[String]>(initialState: []) + let sut = RunnerService(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: testDependency(of: UserDefaultsClient.self) { + $0.set = { value, key in + let entry = "set: \(key) = \(value ?? "nil")" + setCallStack.withLock { $0.append(entry) } + } + } + )) + sut.updateRunnerPaused(true) + #expect(appState.withLock(\.runnerPauses.latestValue) == true) + #expect(setCallStack.withLock(\.self) == ["set: IS_RUNNER_PAUSED = true"]) + } + @Test func updateRunnerSpeed_sends_speed_proportional_to_cpu_usage() { let appState = AllocatedUnfairLock(initialState: .init()) diff --git a/LocalPackage/Tests/ModelTests/StoreTests/DashboardTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/DashboardTests.swift index 8182a91..d6e1b95 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/DashboardTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/DashboardTests.swift @@ -124,6 +124,29 @@ struct DashboardTests { } + @MainActor @Test + func send_pauseButtonTapped_toggles_isPaused_and_sends_runnerPauses() async { + let appState = AllocatedUnfairLock(initialState: .init()) + let setCallStack = AllocatedUnfairLock<[String]>(initialState: []) + let sut = Dashboard(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: testDependency(of: UserDefaultsClient.self) { + $0.set = { value, key in + let entry = "set: \(key) = \(value ?? "nil")" + setCallStack.withLock { $0.append(entry) } + } + } + )) + #expect(sut.isPaused == false) + await sut.send(.pauseButtonTapped) + #expect(sut.isPaused == true) + #expect(appState.withLock(\.runnerPauses.latestValue) == true) + #expect(setCallStack.withLock(\.self) == ["set: IS_RUNNER_PAUSED = true"]) + await sut.send(.pauseButtonTapped) + #expect(sut.isPaused == false) + #expect(appState.withLock(\.runnerPauses.latestValue) == false) + } + @MainActor @Test func send_quitButtonTapped() async { let callStack = AllocatedUnfairLock<[String]>(initialState: []) diff --git a/LocalPackage/Tests/ModelTests/StoreTests/RunnerBarTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/RunnerBarTests.swift index 3c74755..4a0a9d7 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/RunnerBarTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/RunnerBarTests.swift @@ -22,6 +22,9 @@ struct RunnerBarTests { setColor: { _, _ in }, setSpeed: { speed in callStack.withLock { $0.append("setSpeed: \(speed)") } + }, + setPaused: { isPaused in + callStack.withLock { $0.append("setPaused: \(isPaused)") } } ) } @@ -63,6 +66,18 @@ struct RunnerBarTests { await sut.send(.onDisappear) } + @MainActor @Test + func send_task_applies_runner_paused_to_event_bridge() async { + let appState = AllocatedUnfairLock(initialState: .init()) + appState.withLock { $0.runnerPauses.send(true) } + let callStack = AllocatedUnfairLock<[String]>(initialState: []) + let sut = RunnerBar(.testDependencies(appStateClient: .testDependency(appState))) + await sut.send(.task("RunnerBarTests", makeEventBridge(recording: callStack))) + await waitUntil { !callStack.withLock(\.self).isEmpty } + #expect(callStack.withLock(\.self) == ["setPaused: true"]) + await sut.send(.onDisappear) + } + @MainActor @Test func send_task_ignores_thumbnail_bundle() async { let appState = AllocatedUnfairLock(initialState: .init())