diff --git a/Sources/Flock/Theme/ChromeMetrics.swift b/Sources/Flock/Theme/ChromeMetrics.swift index d64dc8b8..09dadf3c 100644 --- a/Sources/Flock/Theme/ChromeMetrics.swift +++ b/Sources/Flock/Theme/ChromeMetrics.swift @@ -142,10 +142,6 @@ enum ChromeMetrics { static let markEchoOpacity: Double = 0.4 /// A finished herd, which stays listed until its workspace closes. static let finishedOpacity: Double = 0.45 - /// The loader's own loop, slowed and shallowed: rail furniture that - /// moves is only ever a hint, never a spinner. - static let motionTempo: Double = 0.4 - static let motionDepth: Double = 0.5 } enum Strip { diff --git a/Sources/Flock/Views/HerdsSection.swift b/Sources/Flock/Views/HerdsSection.swift index 9b6a86e8..91193f37 100644 --- a/Sources/Flock/Views/HerdsSection.swift +++ b/Sources/Flock/Views/HerdsSection.swift @@ -24,7 +24,7 @@ struct HerdsSection: View { // The section's one mark. Rows carry none: one moving glyph // says "something in here is still going" without a row of // them competing for the eye. - HerdMark(theme: theme, size: ChromeMetrics.RailSection.herdsMark, isMoving: summary.isAnyRunning) + HerdMark(theme: theme, size: ChromeMetrics.RailSection.herdsMark) .offset(y: -ChromeMetrics.RailSection.herdsMarkLift) } trailing: { // The summary is the only notice a folded section gives, so @@ -78,28 +78,14 @@ private struct HerdRow: View { } } -/// The ram as a grey glyph, drifting while its herd works and still when it -/// does not. Only the echoes move, as in the loader, so the leader stays put -/// where the eye already found it. +/// The ram as a grey glyph, never animated: a per-frame timeline here +/// re-lays out the whole window on every display frame while a herd runs. struct HerdMark: View { let theme: Theme let size: CGFloat - let isMoving: Bool - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var start = Date() var body: some View { - if isMoving && !reduceMotion { - TimelineView(.animation) { context in - let elapsed = context.date.timeIntervalSince(start) * ChromeMetrics.Herds.motionTempo - HerdrRamMark(size: size, progress: { delay in - PaneLoaderChoreography.mergeProgress(elapsed: elapsed, startDelay: delay) * ChromeMetrics.Herds.motionDepth - }, tint: theme.textLabel, echoOpacityScale: ChromeMetrics.Herds.markEchoOpacity) - } - } else { - HerdrRamMark(size: size, tint: theme.textLabel, echoOpacityScale: ChromeMetrics.Herds.markEchoOpacity) - } + HerdrRamMark(size: size, tint: theme.textLabel, echoOpacityScale: ChromeMetrics.Herds.markEchoOpacity) } } diff --git a/Sources/Flock/Views/PaneLoaderView.swift b/Sources/Flock/Views/PaneLoaderView.swift index 12bb77be..4eff1a70 100644 --- a/Sources/Flock/Views/PaneLoaderView.swift +++ b/Sources/Flock/Views/PaneLoaderView.swift @@ -17,8 +17,9 @@ import SwiftUI /// /// The mark's brand colours are `HerdrRamTrail`'s, shared with the app icon /// so this can never repaint the ram in the active theme; the trail's dots -/// follow `theme`. Under reduced motion the mark rests, still, in the -/// bottom-right corner the run starts from. +/// follow `theme`. Under reduced motion, and once the run has gone on for +/// `PaneLoaderPolicy.motionLimit`, the mark rests, still, in the bottom-right +/// corner the run starts from. struct PaneLoaderView: View { let theme: Theme /// Lets a render test force the static path deterministically. Always @@ -31,6 +32,7 @@ struct PaneLoaderView: View { @Environment(\.accessibilityReduceMotion) private var systemReduceMotion @State private var appeared = false @State private var start = Date() + @State private var hasRunOut = false private var reduceMotion: Bool { reducedMotionOverride ?? systemReduceMotion } @@ -45,7 +47,7 @@ struct PaneLoaderView: View { var body: some View { Group { - if reduceMotion { + if reduceMotion || hasRunOut { HerdrRamMark(size: Self.markSize) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) .padding(ChromeMetrics.Loader.badgeInset) @@ -61,6 +63,10 @@ struct PaneLoaderView: View { .onAppear { withAnimation(.easeOut(duration: PaneLoaderPolicy.dismissCrossFade)) { appeared = true } } + .task { + guard frozenElapsed == nil, (try? await Task.sleep(for: PaneLoaderPolicy.motionLimit)) != nil else { return } + withAnimation(.easeOut(duration: PaneLoaderPolicy.dismissCrossFade)) { hasRunOut = true } + } // Nothing here is aimable, and the pane underneath owns every click // it would otherwise swallow. .allowsHitTesting(false) diff --git a/Sources/Flock/Views/WorkspaceMark.swift b/Sources/Flock/Views/WorkspaceMark.swift index 6da77e4c..59b1a010 100644 --- a/Sources/Flock/Views/WorkspaceMark.swift +++ b/Sources/Flock/Views/WorkspaceMark.swift @@ -191,7 +191,7 @@ struct WorkspaceMark: View { .frame(width: size, height: size) .accessibilityHidden(true) } else { - HerdMark(theme: theme, size: size, isMoving: false) + HerdMark(theme: theme, size: size) .frame(width: size, height: size) } } diff --git a/Sources/FlockCore/Herdr/HerdRail.swift b/Sources/FlockCore/Herdr/HerdRail.swift index b558a3a7..129c52ee 100644 --- a/Sources/FlockCore/Herdr/HerdRail.swift +++ b/Sources/FlockCore/Herdr/HerdRail.swift @@ -20,11 +20,9 @@ public struct HerdRail: Equatable, Sendable { public struct Summary: Equatable, Sendable { public let running: Int public let done: Int - public let isAnyRunning: Bool /// Herds finished out of all herds, in the same N/M form each herd - /// row uses for its workers. Whether anything is still moving is the - /// glyph's to say, which is what lets this fit the default rail + /// row uses for its workers, short enough to fit the default rail /// beside the heading and its chevron. public var text: String { "\(done)/\(running + done) done" } } @@ -38,7 +36,7 @@ public struct HerdRail: Equatable, Sendable { static func summary(of herds: [Herd]) -> Summary? { guard !herds.isEmpty else { return nil } let done = herds.filter(\.isFinished).count - return Summary(running: herds.count - done, done: done, isAnyRunning: herds.contains(where: \.isRunning)) + return Summary(running: herds.count - done, done: done) } /// `progress` is rt's count for each herd it answered for, keyed by diff --git a/Sources/FlockCore/Marks/PaneLoaderPolicy.swift b/Sources/FlockCore/Marks/PaneLoaderPolicy.swift index 9755a291..aa214cb2 100644 --- a/Sources/FlockCore/Marks/PaneLoaderPolicy.swift +++ b/Sources/FlockCore/Marks/PaneLoaderPolicy.swift @@ -34,6 +34,10 @@ public enum PaneLoaderPolicy { /// `Duration` because SwiftUI's animation curves take a `Double`. public static let dismissCrossFade: Double = 0.1 + /// How long the badge runs before it rests. A running badge redraws every + /// display frame, so a pane that never paints must not hold that cost. + public static let motionLimit: Duration = .seconds(10) + public static func dismissAt( shownAt: ContinuousClock.Instant, firstFrameAt: ContinuousClock.Instant, diff --git a/Tests/FlockCoreTests/HerdRailTests.swift b/Tests/FlockCoreTests/HerdRailTests.swift index 63da288d..04261027 100644 --- a/Tests/FlockCoreTests/HerdRailTests.swift +++ b/Tests/FlockCoreTests/HerdRailTests.swift @@ -204,17 +204,15 @@ final class HerdRailTests: XCTestCase { ])).summary XCTAssertEqual(summary?.running, 2) XCTAssertEqual(summary?.done, 1) - XCTAssertEqual(summary?.isAnyRunning, true) XCTAssertEqual(summary?.text, "1/3 done") } - func testEveryHerdDoneReadsOnlyDoneAndStops() { + func testEveryHerdDoneReadsOnlyDone() { let summary = HerdRail(model: model([ WorkspaceSpec(id: "w1", label: "herd: review-shapes", statuses: [.done]), WorkspaceSpec(id: "w2", label: "herd: ci-sweep", statuses: [.idle, .done]), ])).summary XCTAssertEqual(summary?.text, "2/2 done") - XCTAssertEqual(summary?.isAnyRunning, false) } func testNoHerdDoneYetReadsOnlyRunning() { @@ -222,17 +220,14 @@ final class HerdRailTests: XCTestCase { WorkspaceSpec(id: "w1", label: "herd: review-shapes", statuses: [.working]), ])).summary XCTAssertEqual(summary?.text, "0/1 done") - XCTAssertEqual(summary?.isAnyRunning, true) } - /// A herd held open by a pane with no agent still counts as running, - /// while the mark stays still because nothing in it is working. - func testAnOpenHerdWithNothingWorkingIsRunningButStill() { + /// A herd held open by a pane with no agent still counts as running. + func testAnOpenHerdWithNothingWorkingIsRunning() { let summary = HerdRail(model: model([ WorkspaceSpec(id: "w1", label: "herd: review-shapes", statuses: [.done, .unknown]), ])).summary XCTAssertEqual(summary?.running, 1) - XCTAssertEqual(summary?.isAnyRunning, false) } // MARK: - Lookup by id diff --git a/docs/releases/0.9.1.md b/docs/releases/0.9.1.md new file mode 100644 index 00000000..9df43df6 --- /dev/null +++ b/docs/releases/0.9.1.md @@ -0,0 +1,4 @@ +## Fixes + +- Flock no longer burns most of a CPU core while a herd is running. The ram beside Herds now stays still. +- A pane that takes a long time to attach stops animating its loading badge after 10 seconds.