Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 8 additions & 3 deletions Sources/MonitorUI/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
161 changes: 161 additions & 0 deletions Sources/MonitorUI/DialHand.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
82 changes: 48 additions & 34 deletions Sources/MonitorUI/GaugeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -99,20 +90,26 @@ 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
self.fullScale = fullScale
self.peak = peak
self.unit = unit
self.travelTime = travelTime
self.liveHands = liveHands
}

private var fraction: Double {
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading