From 9a5854d08f14b4b41d465405ce1eb3722e6fc16d Mon Sep 17 00:00:00 2001 From: "evan-agent[bot]" <331059279+evan-agent[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:37:48 -0400 Subject: [PATCH] Turn the gauge hands with Core Animation, not SwiftUI (#69) The needle's travel time is the sampling interval, so it is always mid-sweep. As an animatable SwiftUI Shape that kept the whole panel re-evaluating on the main thread at the display's refresh rate, forever: 41.6% of a core at rest with ten gauges and thirteen charts. DialHand is a layer rotated by a CABasicAnimation, which the render server runs, so the main thread wakes once per sample and the needle still sweeps. An interrupted sweep continues from the angle the hand has reached, tracked rather than read back from the layer, since a 240-degree dial folds into plus or minus 180 degrees there. The hand is not hit-testable, so a tile's drag still starts on it. ImageRenderer cannot draw a layer, so Copy Image draws a dial with the still NeedleShape and PeakMarkShape instead. Measured with the saved layout, mean of six 5-second top readings after a 20-second warm-up: main (c887960) 41.6% hands not animated at all 8.6% chart plot-width reader removed 40.6% (not the cause) this change 6.8% Tests: DialHandTests pins the layer animation, the interrupted sweep, and the click pass-through; checks that a copied dial keeps its needle; compares where the live and still needles land at five readings; and fails on any SwiftUI .animation or withAnimation in MonitorUI outside an allowlist. That guard was checked by putting the old animation back: it failed. --- AGENTS.md | 9 + Sources/MonitorUI/DashboardView.swift | 11 +- Sources/MonitorUI/DialHand.swift | 161 +++++++++++++++++ Sources/MonitorUI/GaugeView.swift | 82 +++++---- Tests/MonitorUITests/DialHandTests.swift | 218 +++++++++++++++++++++++ 5 files changed, 444 insertions(+), 37 deletions(-) create mode 100644 Sources/MonitorUI/DialHand.swift create mode 100644 Tests/MonitorUITests/DialHandTests.swift diff --git a/AGENTS.md b/AGENTS.md index 5c22152..19db62a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,15 @@ are no component-level AGENTS.md files. keyed by level because the *name* differs across silicon ("Super" on M4, "Performance" earlier). `host_processor_info` numbers cores in reverse perflevel order — slowest cluster first. +- **Gauge hands are turned by Core Animation, never by SwiftUI** (#69). + `DialHand` is a layer rotated by a `CABasicAnimation`, which the render server + runs. The needle's travel time is the sampling interval, so it is always + mid-sweep: as an animatable SwiftUI `Shape` it kept the whole panel + re-evaluating at the display's refresh rate, 41.6% of a core at rest against + 6.8% now. `DialHandTests` fails on any `.animation(` or `withAnimation` in + `MonitorUI` outside its allowlist. `ImageRenderer` cannot draw a layer, so + **Copy Image** draws a dial with the still `NeedleShape` and `PeakMarkShape` + (`liveHands: false`), and a test checks the two point the same way. - **Gauges are for rates, charts are for levels.** A dial answers "how hard is this working right now against what it can do" (disk, network throughput). A chart answers "what has been happening" (CPU, memory). `LayoutDefaults` diff --git a/Sources/MonitorUI/DashboardView.swift b/Sources/MonitorUI/DashboardView.swift index 1a1fe84..35d7d04 100644 --- a/Sources/MonitorUI/DashboardView.swift +++ b/Sources/MonitorUI/DashboardView.swift @@ -201,7 +201,10 @@ public struct DashboardView: View { for: [(descriptor, model.points(metric))], window: window ) } rendered: { - copyBackground(dial.frame(width: gaugeSize)) + copyBackground( + gaugeTile(metric, descriptor, size: gaugeSize, forImage: true) + .frame(width: gaugeSize) + ) } .reorderable( .gauge(metric.rawValue), target: $dropTarget, onDrop: dropGauge @@ -296,7 +299,7 @@ public struct DashboardView: View { /// picture a second time — the tile in the wall carries the drag machinery, /// and the copy wants only the dial. private func gaugeTile( - _ metric: MetricID, _ descriptor: MetricDescriptor, size: Double + _ metric: MetricID, _ descriptor: MetricDescriptor, size: Double, forImage: Bool = false ) -> some View { VStack(spacing: 2) { GaugeView( @@ -307,7 +310,9 @@ public struct DashboardView: View { unit: descriptor.unit, // Needle travel matches the sampling interval, so it is still // moving when the next sample arrives. - travelTime: model.interval + travelTime: model.interval, + // `ImageRenderer` cannot draw the live hands' layers (#69). + liveHands: !forImage ) // The dial's label, not its value. The readout on the face already // carries the number and its unit, so repeating it here was the diff --git a/Sources/MonitorUI/DialHand.swift b/Sources/MonitorUI/DialHand.swift new file mode 100644 index 0000000..dc33c43 --- /dev/null +++ b/Sources/MonitorUI/DialHand.swift @@ -0,0 +1,161 @@ +import AppKit +import QuartzCore +import SwiftUI + +/// A gauge hand — the needle or the peak mark — turned by Core Animation. +/// +/// It replaced an animatable `Shape` (#69). SwiftUI interpolates an animatable +/// shape on the main thread, once per display frame, and while any animation is +/// running it also re-evaluates the view graph around it. The needle's travel +/// time is the sampling interval, so on a one-second clock there is always an +/// animation running: ten gauges held a dashboard of charts at 40% of a core, +/// all of it spent redrawing between samples. A `CABasicAnimation` is run by +/// the render server instead, so the main thread wakes once per sample and the +/// hand still sweeps rather than jumping. +/// +/// The hand is drawn once, pointing at the start of the dial, and turned by the +/// layer's rotation. Only the rotation animates; a resize redraws the path +/// with actions off, so the hand never animates its length. +struct DialHand: NSViewRepresentable { + /// Position on the dial, 0...1. + var fraction: Double + /// Where the hand starts and ends, as fractions of the dial radius. A + /// negative start is a tail behind the pivot. + var from: Double + var to: Double + var color: Color + /// Stroke width for a given dial radius. + var lineWidth: (Double) -> Double + var lineCap: CAShapeLayerLineCap + var travelTime: TimeInterval + + func makeNSView(context _: Context) -> DialHandView { + DialHandView() + } + + func updateNSView(_ view: DialHandView, context _: Context) { + view.configure( + from: from, to: to, color: NSColor(color), lineWidth: lineWidth, lineCap: lineCap + ) + view.turn(to: fraction, over: travelTime) + } +} + +final class DialHandView: NSView { + private let container = CALayer() + /// Internal rather than private so the tests can read its animation. + let hand = CAShapeLayer() + private var from = 0.0 + private var to = 0.0 + private var lineWidth: (Double) -> Double = { _ in 1 } + + /// The rotation the hand is heading for, and the one it left from. Kept + /// here rather than read back from the presentation layer: the dial sweeps + /// 240°, and `transform.rotation.z` read from a transform comes back folded + /// into ±180°, which would send an interrupted hand the long way round. + private var target = 0.0 + private var origin = 0.0 + private var started = 0.0 + private var duration = 0.0 + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + // y-down inside the container, as in SwiftUI, so the angles below are + // the same angles the face is drawn with. + container.isGeometryFlipped = true + hand.fillColor = nil + hand.lineCap = .round + container.addSublayer(hand) + layer?.addSublayer(container) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not used") + } + + /// The tile under the gauge takes the drag that reorders it, so the hand + /// must never be the thing a click lands on. + override func hitTest(_: NSPoint) -> NSView? { + nil + } + + func configure( + from: Double, + to: Double, + color: NSColor, + lineWidth: @escaping (Double) -> Double, + lineCap: CAShapeLayerLineCap + ) { + let changed = from != self.from || to != self.to + self.from = from + self.to = to + self.lineWidth = lineWidth + CATransaction.begin() + CATransaction.setDisableActions(true) + hand.strokeColor = color.cgColor + hand.lineCap = lineCap + CATransaction.commit() + if changed { needsLayout = true } + } + + override func layout() { + super.layout() + CATransaction.begin() + CATransaction.setDisableActions(true) + container.frame = bounds + let radius = min(bounds.width, bounds.height) / 2 + let center = CGPoint(x: bounds.midX, y: bounds.midY) + hand.bounds = CGRect(origin: .zero, size: bounds.size) + hand.position = center + hand.lineWidth = lineWidth(radius) + let start = GaugeGeometry.dialStart.radians + let mid = CGPoint(x: bounds.width / 2, y: bounds.height / 2) + func point(_ distance: Double) -> CGPoint { + CGPoint(x: mid.x + distance * cos(start), y: mid.y + distance * sin(start)) + } + let path = CGMutablePath() + path.move(to: point(radius * from)) + path.addLine(to: point(radius * to)) + hand.path = path + CATransaction.commit() + } + + /// Where the hand is now, on the curve it is travelling. + private func current(at now: Double) -> Double { + guard duration > 0 else { return target } + let progress = min(1, max(0, (now - started) / duration)) + // Ease-out, matching the animation's timing closely enough that an + // interruption does not visibly jump. + let eased = 1 - (1 - progress) * (1 - progress) + return origin + (target - origin) * eased + } + + func turn( + to fraction: Double, over travelTime: TimeInterval, now: Double = CACurrentMediaTime() + ) { + let angle = GaugeGeometry.dialSweep.radians * min(1, max(0, fraction)) + guard angle != target else { return } + let from = current(at: now) + origin = from + target = angle + started = now + duration = window == nil ? 0 : travelTime + + CATransaction.begin() + CATransaction.setDisableActions(true) + hand.setValue(angle, forKeyPath: "transform.rotation.z") + CATransaction.commit() + guard duration > 0 else { + hand.removeAnimation(forKey: "turn") + return + } + let animation = CABasicAnimation(keyPath: "transform.rotation.z") + animation.fromValue = from + animation.toValue = angle + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + hand.add(animation, forKey: "turn") + } +} diff --git a/Sources/MonitorUI/GaugeView.swift b/Sources/MonitorUI/GaugeView.swift index c64f5ea..f1150ea 100644 --- a/Sources/MonitorUI/GaugeView.swift +++ b/Sources/MonitorUI/GaugeView.swift @@ -3,30 +3,26 @@ import SwiftUI /// The dial sweeps 240°, from lower left to lower right, leaving the bottom /// open for the readout. A full 360° dial has no unambiguous zero. -private let dialStart = Angle(degrees: 150) -private let dialSweep = Angle(degrees: 240) +enum GaugeGeometry { + static let dialStart = Angle(degrees: 150) + static let dialSweep = Angle(degrees: 240) +} + +private let dialStart = GaugeGeometry.dialStart +private let dialSweep = GaugeGeometry.dialSweep /// Fraction of the sweep marked as redline. private let redlineStart = 0.8 -/// The needle, as a `Shape`. -/// -/// This is a `Shape` and not part of the `Canvas` for one specific reason: -/// `Canvas` draws imperatively from whatever the closure reads, so SwiftUI -/// cannot interpolate it and the needle jumps once per sample — at a one-second -/// sampling rate the gauge visibly runs at 1 fps. A `Shape` with -/// `animatableData` is interpolated by SwiftUI at the display refresh rate, so -/// the needle sweeps between readings instead of teleporting. +/// The needle, as a `Shape`: the still picture of it. /// -/// The face behind it stays in a `Canvas`, because ticks and labels do not move. +/// The live panel does not draw this. There the hands are `DialHand`, turned +/// by Core Animation (#69). But `ImageRenderer`, which **Copy Image** uses, +/// cannot draw an `NSViewRepresentable`, so a copied dial is drawn with these +/// shapes instead, at the needle's current reading. struct NeedleShape: Shape { /// Position on the dial, 0...1. var fraction: Double - var animatableData: Double { - get { fraction } - set { fraction = newValue } - } - func path(in rect: CGRect) -> Path { let radius = min(rect.width, rect.height) / 2 let center = CGPoint(x: rect.midX, y: rect.midY) @@ -48,15 +44,10 @@ struct NeedleShape: Shape { } } -/// The recent-peak marker, animatable for the same reason as the needle. +/// The recent-peak marker's still picture, for the same reason as the needle. struct PeakMarkShape: Shape { var fraction: Double - var animatableData: Double { - get { fraction } - set { fraction = newValue } - } - func path(in rect: CGRect) -> Path { let radius = min(rect.width, rect.height) / 2 let center = CGPoint(x: rect.midX, y: rect.midY) @@ -99,13 +90,18 @@ public struct GaugeView: View { /// which is the correct trade for an instrument you read at a glance. public var travelTime: TimeInterval = 1.0 + /// Whether the hands are live. False for a dial rendered to an image, + /// which cannot hold a layer and would otherwise lose its needle. + public var liveHands = true + public init( title: String, value: Double, fullScale: Double, peak: Double? = nil, unit: MetricUnit, - travelTime: TimeInterval = 1.0 + travelTime: TimeInterval = 1.0, + liveHands: Bool = true ) { self.title = title self.value = value @@ -113,6 +109,7 @@ public struct GaugeView: View { self.peak = peak self.unit = unit self.travelTime = travelTime + self.liveHands = liveHands } private var fraction: Double { @@ -140,18 +137,35 @@ public struct GaugeView: View { drawRedline(context: context, center: center, radius: radius) } - if peak != nil { - PeakMarkShape(fraction: peakFraction) - .stroke(Theme.readout.opacity(0.55), lineWidth: 1.5) - .animation(.easeOut(duration: travelTime), value: peakFraction) - } - - NeedleShape(fraction: fraction) - .stroke( - Theme.needle, - style: StrokeStyle(lineWidth: max(2, radius * 0.045), lineCap: .round) + // Both hands are turned by Core Animation, not SwiftUI; see + // `DialHand` for why (#69). An image gets the still shapes. + if liveHands { + if peak != nil { + DialHand( + fraction: peakFraction, from: 0.64, to: 0.84, + color: Theme.readout.opacity(0.55), lineWidth: { _ in 1.5 }, + lineCap: .butt, travelTime: travelTime + ) + } + DialHand( + fraction: fraction, from: -0.12, to: 0.78, + color: Theme.needle, lineWidth: { max(2, $0 * 0.045) }, + lineCap: .round, travelTime: travelTime ) - .animation(.easeOut(duration: travelTime), value: fraction) + } else { + if peak != nil { + PeakMarkShape(fraction: peakFraction) + .stroke(Theme.readout.opacity(0.55), lineWidth: 1.5) + } + NeedleShape(fraction: fraction) + .stroke( + Theme.needle, + style: StrokeStyle( + lineWidth: max(2, radius * 0.045), + lineCap: .round + ) + ) + } hub(radius: radius) readout(radius: radius) diff --git a/Tests/MonitorUITests/DialHandTests.swift b/Tests/MonitorUITests/DialHandTests.swift new file mode 100644 index 0000000..41a2123 --- /dev/null +++ b/Tests/MonitorUITests/DialHandTests.swift @@ -0,0 +1,218 @@ +import AppKit +import Foundation +@testable import MonitorUI +import QuartzCore +import SwiftUI +import Testing + +/// The gauge hands are turned by Core Animation, not SwiftUI (#69). +/// +/// With SwiftUI animating them, a hand whose travel time equals the sampling +/// interval is always mid-sweep, so the whole panel was re-evaluated at the +/// display's refresh rate forever: 41.6% of a core at rest, against 8.3% with +/// the same sweep run by the render server. These pin the mechanism; the +/// source guard at the bottom pins the rule. +@MainActor +@Suite("DialHand") +struct DialHandTests { + private func hosted() -> (DialHandView, NSWindow) { + let view = DialHandView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + let window = NSWindow( + contentRect: view.frame, styleMask: .borderless, backing: .buffered, defer: true + ) + window.contentView = view + return (view, window) + } + + private var sweep: Double { + GaugeGeometry.dialSweep.radians + } + + @Test("a new reading is a Core Animation sweep on the layer") + func sweepIsALayerAnimation() throws { + let (view, window) = hosted() + view.turn(to: 0.5, over: 1.0, now: 0) + let animation = try #require(view.hand.animation(forKey: "turn") as? CABasicAnimation) + #expect(animation.keyPath == "transform.rotation.z") + #expect(animation.duration == 1.0) + #expect((animation.toValue as? Double) == sweep * 0.5) + _ = window + } + + @Test("an interrupted sweep starts where the hand is, not where it was going") + func interruptionContinuesFromTheCurrentAngle() throws { + let (view, window) = hosted() + view.turn(to: 1.0, over: 1.0, now: 0) + // Halfway through an ease-out, three-quarters of the way there. + view.turn(to: 0.0, over: 1.0, now: 0.5) + let animation = try #require(view.hand.animation(forKey: "turn") as? CABasicAnimation) + let from = try #require(animation.fromValue as? Double) + #expect(abs(from - sweep * 0.75) < 1e-9) + // 180° exactly here, and past it for a larger sweep: a value read back + // from the layer's transform would fold into ±180° and turn the long way. + #expect(from > 0) + _ = window + } + + @Test("off screen the hand snaps rather than animating") + func noWindowNoAnimation() { + let view = DialHandView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + view.turn(to: 0.5, over: 1.0, now: 0) + #expect(view.hand.animation(forKey: "turn") == nil) + } + + @Test("the same reading twice starts nothing new") + func unchangedValueIsIgnored() { + let (view, window) = hosted() + view.turn(to: 0.5, over: 1.0, now: 0) + view.hand.removeAllAnimations() + view.turn(to: 0.5, over: 1.0, now: 0.1) + #expect(view.hand.animation(forKey: "turn") == nil) + _ = window + } + + @Test("a click passes through the hand to the tile's drag") + func handIsNotHitTestable() { + let (view, window) = hosted() + #expect(view.hitTest(NSPoint(x: 50, y: 50)) == nil) + _ = window + } + + /// The rule that #69 broke, as a test: nothing in the panel runs a SwiftUI + /// animation on a per-sample value. One that lasts as long as the sampling + /// interval never stops, and SwiftUI runs it on the main thread for the + /// whole view graph. Animate on a layer (`DialHand`) instead. The allowlist + /// is for one-shot animations that start from a gesture and end. + @Test("no SwiftUI animation in the panel outside the allowlist") + func noSwiftUIAnimationOnSampledValues() throws { + let allowed: Set = [ + "ReorderDrag.swift", // the drop indicator: 0.12 s, only during a drag + ] + let ui = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Sources/MonitorUI") + let files = try FileManager.default.contentsOfDirectory(atPath: ui.path) + .filter { $0.hasSuffix(".swift") && !allowed.contains($0) } + #expect(!files.isEmpty) + for file in files { + let source = try String( + contentsOf: ui.appendingPathComponent(file), + encoding: .utf8 + ) + let code = source.split(separator: "\n").filter { + !$0.trimmingCharacters(in: .whitespaces).hasPrefix("//") + } + for line in code + where line.contains(".animation(") || line.contains("withAnimation") + { + Issue.record("\(file): a SwiftUI animation (#69): \(line)") + } + } + } +} + +/// **Copy Image** renders a dial with `ImageRenderer`, which cannot draw the +/// live hands' layers. A copied dial must still show its needle. +@MainActor +@Suite("Gauge image") +struct GaugeImageTests { + /// Pixels close to the needle's blue in a render of the dial. + private func needlePixels(liveHands: Bool) throws -> Int { + let gauge = GaugeView( + title: "Test", value: 50, fullScale: 100, unit: .celsius, liveHands: liveHands + ) + .frame(width: 200, height: 200) + let renderer = ImageRenderer(content: gauge) + renderer.scale = 1 + let image = try #require(renderer.cgImage) + let bitmap = NSBitmapImageRep(cgImage: image) + var count = 0 + for x in stride(from: 0, to: bitmap.pixelsWide, by: 1) { + for y in stride(from: 0, to: bitmap.pixelsHigh, by: 1) { + guard let c = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.sRGB) + else { continue } + if c.redComponent < 0.35, c.greenComponent > 0.5, c.blueComponent > 0.8 { + count += 1 + } + } + } + return count + } + + @Test("a copied dial keeps its needle") + func imageHasANeedle() throws { + #expect(try needlePixels(liveHands: false) > 50) + } + + @Test("the live hands are invisible to ImageRenderer, which is why copies use the shapes") + func liveHandsDoNotRender() throws { + #expect(try needlePixels(liveHands: true) == 0) + } +} + +/// The live hand and the still one must point the same way, or a copied dial +/// shows a different reading from the panel it was copied from. Nobody can +/// look at both at once, so this compares where their pixels land. +@MainActor +@Suite("Hand geometry") +struct HandGeometryTests { + private static let side = 200 + + /// Mean position of the needle-coloured pixels, in a y-down bitmap. + private func centroid(_ image: CGImage) -> CGPoint? { + let bitmap = NSBitmapImageRep(cgImage: image) + var sum = CGPoint.zero + var count = 0.0 + for x in 0.. 0.5, c.blueComponent > 0.8 + else { continue } + sum.x += Double(x) + sum.y += Double(y) + count += 1 + } + } + return count == 0 ? nil : CGPoint(x: sum.x / count, y: sum.y / count) + } + + private func still(_ fraction: Double) throws -> CGImage { + let view = NeedleShape(fraction: fraction) + .stroke(Theme.needle, style: StrokeStyle(lineWidth: 9, lineCap: .round)) + .frame(width: Double(Self.side), height: Double(Self.side)) + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + return try #require(renderer.cgImage) + } + + private func live(_ fraction: Double) throws -> CGImage { + let frame = NSRect(x: 0, y: 0, width: Self.side, height: Self.side) + let view = DialHandView(frame: frame) + view.configure( + from: -0.12, to: 0.78, color: NSColor(Theme.needle), lineWidth: { _ in 9 }, + lineCap: .round + ) + view.layout() + view.turn(to: fraction, over: 0) // not in a window: snaps, no animation + let context = try #require(CGContext( + data: nil, width: Self.side, height: Self.side, bitsPerComponent: 8, + bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + // No flip: Core Animation on macOS is y-up, like a bare CGContext, so + // the layer renders the way it looks on screen, and the image's first + // row is its top, as in the still render. + try #require(view.layer).render(in: context) + return try #require(context.makeImage()) + } + + @Test( + "the live needle points where the still one does", + arguments: [0.0, 0.25, 0.5, 0.9, 1.0] + ) + func samePlace(fraction: Double) throws { + let a = try #require(centroid(still(fraction))) + let b = try #require(centroid(live(fraction))) + #expect(abs(a.x - b.x) < 3 && abs(a.y - b.y) < 3, "still \(a), live \(b)") + } +}