diff --git a/Sources/CodingBar/StatusItemController.swift b/Sources/CodingBar/StatusItemController.swift index 540d1c8..2e8090d 100644 --- a/Sources/CodingBar/StatusItemController.swift +++ b/Sources/CodingBar/StatusItemController.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import CodingBarCore @@ -8,6 +9,8 @@ final class StatusItemController: NSObject, NSPopoverDelegate { private let store: UsageStore private var statusItem: NSStatusItem! private var popover: NSPopover! + private var pulse: PulseLayerView! + private var menuWatch: AnyCancellable? init(store: UsageStore) { self.store = store @@ -20,19 +23,47 @@ final class StatusItemController: NSObject, NSPopoverDelegate { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) guard let button = statusItem.button else { return } - // Host the SwiftUI item and let Auto Layout drive the status item's width (true variableLength). - let hosting = NSHostingView(rootView: AnyView(StatusItemContentView(store: store))) - hosting.translatesAutoresizingMaskIntoConstraints = false - button.addSubview(hosting) + // The pulse is AppKit + CALayer, the readout stays SwiftUI. Keeping the animated + // glyph out of the NSHostingView is the whole point: a SwiftUI animation in here + // re-lays-out the status item every frame. See PulseLayerView for the numbers. + let pulse = PulseLayerView() + self.pulse = pulse + pulse.translatesAutoresizingMaskIntoConstraints = false + + // Concrete root type, not AnyView: type erasure defeats SwiftUI's structural + // diffing, so every publish rebuilt the item's tree instead of updating it. + let readout = NSHostingView(rootView: StatusItemContentView(store: store)) + readout.translatesAutoresizingMaskIntoConstraints = false + + button.addSubview(pulse) + button.addSubview(readout) + // Auto Layout still drives the item's width (true variableLength) — but now only + // the readout's text can change it, and that happens on the 30s refresh, not per frame. NSLayoutConstraint.activate([ - hosting.topAnchor.constraint(equalTo: button.topAnchor), - hosting.bottomAnchor.constraint(equalTo: button.bottomAnchor), - hosting.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 4), - hosting.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -4), + pulse.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 4), + pulse.centerYAnchor.constraint(equalTo: button.centerYAnchor), + pulse.widthAnchor.constraint(equalToConstant: PulseLayerView.box.width), + pulse.heightAnchor.constraint(equalToConstant: PulseLayerView.box.height), + readout.leadingAnchor.constraint(equalTo: pulse.trailingAnchor, constant: 6), + readout.topAnchor.constraint(equalTo: button.topAnchor), + readout.bottomAnchor.constraint(equalTo: button.bottomAnchor), + readout.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -4), ]) button.action = #selector(handleClick(_:)) button.target = self button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + + // objectWillChange fires *before* the value lands, so hop a runloop turn to read + // the settled snapshot. + syncPulse() + menuWatch = store.objectWillChange + .receive(on: RunLoop.main) + .sink { [weak self] in self?.syncPulse() } + } + + private func syncPulse() { + let m = store.snapshot.menu + pulse.update(active: m.active, throughput: m.throughput) } /// Left-click toggles the popover; right-click (or ⌃-click) shows a small menu @@ -89,6 +120,7 @@ final class StatusItemController: NSObject, NSPopoverDelegate { } // MARK: - +// Readout only — the glyph is a sibling AppKit view, not part of this tree. private struct StatusItemContentView: View { @ObservedObject var store: UsageStore @@ -96,6 +128,8 @@ private struct StatusItemContentView: View { let s = store.snapshot.menu let menu = MenuSummary(metric: store.menuMetric, primaryText: store.primaryText, quotaPercent: s.quotaPercent, active: s.active, throughput: s.throughput) - MenuBarItemView(menu: menu) + MenuBarReadout(menu: menu) + .frame(height: 22) + .fixedSize() } } diff --git a/Sources/CodingBar/Views/MenuBarItemView.swift b/Sources/CodingBar/Views/MenuBarItemView.swift index a9a2023..af92d9e 100644 --- a/Sources/CodingBar/Views/MenuBarItemView.swift +++ b/Sources/CodingBar/Views/MenuBarItemView.swift @@ -3,6 +3,10 @@ import AppKit import CodingBarCore // MARK: - The menu bar item: [pulse] 6pt [two-line equal-width number block] +// This whole-item composition is what the offscreen renderer rasterizes. The live status +// item builds the same layout out of a `PulseLayerView` plus `MenuBarReadout`, because a +// SwiftUI animation inside the NSStatusItem costs a full re-layout per frame — see +// PulseLayerView. Both paths draw from `PulseGlyph`, so they stay the same mark. struct MenuBarItemView: View { let menu: MenuSummary @@ -10,32 +14,43 @@ struct MenuBarItemView: View { var body: some View { HStack(spacing: 6) { - PulseIcon(active: menu.active, throughput: menu.throughput) + PulseIcon(active: menu.active) // Crisp white on a dark menu bar (black on a light one) — not the // slightly-gray 85%-alpha labelColor. The pulse line follows this // tint; the live dot keeps its own green/gray (liveness, not quota // — quota health stays on the meter + % below). .foregroundStyle(colorScheme == .dark ? Color.white : Color.black) - VStack(alignment: .trailing, spacing: 0) { - numberText - if let pct = menu.quotaPercent { - // The two lines share one width: a *hidden copy of the number* - // is the width authority (resolved in a single layout pass — no - // GeometryReader/preference feedback that fails to settle), so - // the quota row is proposed the number's full width. The % sits - // at the left edge, the meter's right edge lines up with the - // number's, and the gap between them absorbs the slack. - ZStack(alignment: .leading) { - numberText.hidden() - line2(pct: pct) - } - } - } + MenuBarReadout(menu: menu) } .frame(height: 22) .fixedSize() } +} + +// MARK: - The number block, without the glyph +struct MenuBarReadout: View { + let menu: MenuSummary + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + numberText + if let pct = menu.quotaPercent { + // The two lines share one width: a *hidden copy of the number* + // is the width authority (resolved in a single layout pass — no + // GeometryReader/preference feedback that fails to settle), so + // the quota row is proposed the number's full width. The % sits + // at the left edge, the meter's right edge lines up with the + // number's, and the gap between them absorbs the slack. + ZStack(alignment: .leading) { + numberText.hidden() + line2(pct: pct) + } + } + } + } private var numberText: some View { Text(menu.primaryText) diff --git a/Sources/CodingBar/Views/PulseIcon.swift b/Sources/CodingBar/Views/PulseIcon.swift index 37d999d..a97a862 100644 --- a/Sources/CodingBar/Views/PulseIcon.swift +++ b/Sources/CodingBar/Views/PulseIcon.swift @@ -28,15 +28,18 @@ enum PulseGlyph { } } -// MARK: - Pulse / heartbeat glyph (menu bar) +// MARK: - Pulse / heartbeat glyph (still frame) // Monochrome white/black pulse (template — tinted by the menu-bar appearance via // the caller's foregroundStyle) with a single green liveness dot at the right -// terminus. When an agent is active the dot breathes (gentle scale+opacity) and -// the whole line pulses — faster as throughput rises; idle, the dot is a steady -// gray and the line is still. +// terminus: green when an agent is active, steady gray when idle. +// +// This is the *static* rendition, used by the offscreen renderer and anywhere the mark +// appears outside the status item. The live menu-bar glyph is `PulseLayerView`, which +// breathes via Core Animation — a SwiftUI animation inside the NSStatusItem re-lays-out +// the entire item every frame (measured: 60% of a core). Both read `PulseGlyph`, so the +// mark is identical. struct PulseIcon: View { var active: Bool - var throughput: Double // Glyph box. The waveform fills an inset rect; the dot caps the right end and // pokes a hair past it, so the box leaves room on the right for the dot. @@ -44,14 +47,6 @@ struct PulseIcon: View { private let lineWidth: CGFloat = 1.5 private let dotRadius: CGFloat = 1.7 - @State private var phase: Double = 0 - @State private var inhale = false - - private var period: Double { - let clamped = min(max(throughput, 0), 2000) - return 1.6 - clamped / 2000 // 1.6s → 0.6s - } - // Inner rect the waveform maps into (leaves room for the round caps and the // dot's radius on the right). The dot's center is the waveform's terminus. private var inner: CGRect { @@ -73,28 +68,13 @@ struct PulseIcon: View { .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) .frame(width: inner.width, height: inner.height) .offset(x: inner.minX, y: inner.minY) - .opacity(active ? 0.78 + 0.22 * sin(phase * .pi * 2) : 1.0) Circle() .fill(dotColor) .frame(width: dotRadius * 2, height: dotRadius * 2) .position(dotCenter) - // Breathe only while active (scale .78→1, opacity .55→1); steady idle. - .opacity(active ? (inhale ? 1.0 : 0.55) : 1.0) - .scaleEffect(active ? (inhale ? 1.0 : 0.78) : 1.0) } .frame(width: box.width, height: box.height) - .onAppear { if active { startPulsing() } } - .onChange(of: active) { _, isActive in - if isActive { startPulsing() } - else { withAnimation(.easeOut(duration: 0.3)) { phase = 0; inhale = false } } - } - } - - private func startPulsing() { - phase = 0 - withAnimation(.linear(duration: period).repeatForever(autoreverses: false)) { phase = 1 } - withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { inhale = true } } } diff --git a/Sources/CodingBar/Views/PulseLayerView.swift b/Sources/CodingBar/Views/PulseLayerView.swift new file mode 100644 index 0000000..0367279 --- /dev/null +++ b/Sources/CodingBar/Views/PulseLayerView.swift @@ -0,0 +1,146 @@ +import AppKit +import SwiftUI + +// MARK: - The menu-bar pulse, drawn on CALayer instead of SwiftUI +// +// The glyph animates continuously, and that is exactly what SwiftUI cannot afford here: +// a running animation re-evaluates the body every frame, and inside an NSStatusItem each +// of those frames drags the whole item through Auto Layout and +// -[NSStatusItem _updateReplicants] (which mirrors the item onto every screen and the +// Control Center). Measured at a permanent 60% of a core while nothing on screen changed +// size — and it stayed there whether the opacity came from a sin() or from two constant +// endpoints, so it is the hosting, not the curve. +// +// Core Animation interpolates on the render server: the app submits the animation once +// and then burns nothing per frame. Geometry still comes from `PulseGlyph`, so this and +// the SwiftUI `PulseIcon` (kept for offscreen rendering) draw the same mark. +final class PulseLayerView: NSView { + // Matches PulseIcon's box exactly — the two must stay visually interchangeable. + static let box = CGSize(width: 18, height: 13) + private let lineWidth: CGFloat = 1.5 + private let dotRadius: CGFloat = 1.7 + + private let waveLayer = CAShapeLayer() + private let dotLayer = CAShapeLayer() + + private var active = false + private var tempoBucket = 0 + + // Flipped so the PulseGlyph design space (y grows downward) maps with the same + // arithmetic the SwiftUI Shape uses. + override var isFlipped: Bool { true } + override var intrinsicContentSize: NSSize { Self.box } + + init() { + super.init(frame: NSRect(origin: .zero, size: Self.box)) + wantsLayer = true + layer?.addSublayer(waveLayer) + layer?.addSublayer(dotLayer) + waveLayer.fillColor = nil + waveLayer.lineWidth = lineWidth + waveLayer.lineCap = .round + waveLayer.lineJoin = .round + dotLayer.strokeColor = nil + buildPaths() + applyTint() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) unused") } + + // The waveform fills an inset rect; the dot caps the right end, so the box leaves + // room on the right for the dot's radius. Same inset math as PulseIcon. + private var inner: CGRect { + CGRect(x: lineWidth / 2, + y: lineWidth / 2, + width: Self.box.width - lineWidth - dotRadius, + height: Self.box.height - lineWidth) + } + + private func buildPaths() { + let r = inner + let path = CGMutablePath() + for (i, point) in PulseGlyph.points.enumerated() { + let n = PulseGlyph.normalized(point) + let pt = CGPoint(x: r.minX + n.x * r.width, y: r.minY + n.y * r.height) + if i == 0 { path.move(to: pt) } else { path.addLine(to: pt) } + } + waveLayer.path = path + waveLayer.frame = CGRect(origin: .zero, size: Self.box) + + let n = PulseGlyph.normalized(PulseGlyph.terminus) + let center = CGPoint(x: r.minX + n.x * r.width, y: r.minY + n.y * r.height) + let d = dotRadius * 2 + // The dot gets its own layer frame so `transform.scale` breathes around its + // center rather than the view's origin. + dotLayer.frame = CGRect(x: center.x - dotRadius, y: center.y - dotRadius, width: d, height: d) + dotLayer.path = CGPath(ellipseIn: CGRect(x: 0, y: 0, width: d, height: d), transform: nil) + } + + // MARK: - Appearance + + /// Crisp white on a dark menu bar, black on a light one — matching PulseIcon's tint + /// rule. The dot keeps its own green/gray (liveness, not quota). + private func applyTint() { + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + waveLayer.strokeColor = (isDark ? NSColor.white : NSColor.black).cgColor + dotLayer.fillColor = (active ? NSColor(Theme.liveGreen) : .tertiaryLabelColor).cgColor + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + applyTint() + } + + // MARK: - State + + /// Drive the glyph from the latest snapshot. Restarting is keyed to `active` and the + /// tempo bucket only — a raw throughput reading would restart the animation on every + /// 30s refresh for no visible gain. + func update(active: Bool, throughput: Double) { + let bucket = Self.tempoBucket(for: throughput) + let changed = active != self.active || bucket != tempoBucket + self.active = active + self.tempoBucket = bucket + applyTint() + guard changed else { return } + if active { startAnimating() } else { stopAnimating() } + } + + private static func tempoBucket(for throughput: Double) -> Int { + min(2, Int(min(max(throughput, 0), 2000) / 667)) + } + + /// 1.6s idle → 0.6s busy, bucketed. + private var period: Double { [1.6, 1.1, 0.6][tempoBucket] } + + private func startAnimating() { + stopAnimating() + // autoreverses doubles each duration, so halve them to keep the original periods + // (line: `period`, dot: 1.5s). + waveLayer.add(breathe(from: 1.0, to: 0.78, duration: period / 2, key: "opacity"), forKey: "pulse") + dotLayer.add(breathe(from: 1.0, to: 0.55, duration: 0.75, key: "opacity"), forKey: "breathe") + dotLayer.add(breathe(from: 1.0, to: 0.78, duration: 0.75, key: "transform.scale"), forKey: "breatheScale") + } + + private func stopAnimating() { + waveLayer.removeAllAnimations() + dotLayer.removeAllAnimations() + waveLayer.opacity = 1 + dotLayer.opacity = 1 + dotLayer.transform = CATransform3DIdentity + } + + private func breathe(from: Double, to: Double, duration: Double, key: String) -> CABasicAnimation { + let a = CABasicAnimation(keyPath: key) + a.fromValue = from + a.toValue = to + a.duration = duration + a.autoreverses = true + a.repeatCount = .infinity + a.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + // Survive the menu bar hiding the item and bringing it back. + a.isRemovedOnCompletion = false + return a + } +} diff --git a/release-notes/v1.1.4.md b/release-notes/v1.1.4.md new file mode 100644 index 0000000..390584b --- /dev/null +++ b/release-notes/v1.1.4.md @@ -0,0 +1,15 @@ +- The menu-bar pulse no longer costs a permanent 60% of a CPU core. The glyph + animated through SwiftUI, and any running animation inside a status item forces + the whole item — text, meter and all — to re-lay-out on every single frame. It + now animates on Core Animation, which the render server interpolates without + the app doing per-frame work. Idle CPU drops to zero; the only activity left is + the 30-second usage refresh. The glyph itself looks exactly the same. +- Fixed the cost compounding the longer CodingBar stayed open. Every time an agent + went from idle to active, another repeating animation was started without the + previous one being cancelled, so they stacked up over days of uptime — a Mac + left running for three weeks measured 198% of a core. +- The pulse tempo now actually follows throughput. It was meant to beat faster as + the agent got busier, but the tempo was only ever applied at the moment the + agent flipped between idle and active, so in practice it never changed. + +**Full Changelog**: https://github.com/Gnonymous/CodingBar/compare/v1.1.3...v1.1.4