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