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)") + } +}