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
52 changes: 43 additions & 9 deletions Sources/CodingBar/StatusItemController.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import Combine
import SwiftUI
import CodingBarCore

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -89,13 +120,16 @@ 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

var body: some 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()
}
}
47 changes: 31 additions & 16 deletions Sources/CodingBar/Views/MenuBarItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,54 @@ 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

@Environment(\.colorScheme) private var colorScheme

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)
Expand Down
36 changes: 8 additions & 28 deletions Sources/CodingBar/Views/PulseIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,25 @@ 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.
private let box = CGSize(width: 18, height: 13)
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 {
Expand All @@ -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 }
}
}

Expand Down
146 changes: 146 additions & 0 deletions Sources/CodingBar/Views/PulseLayerView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
15 changes: 15 additions & 0 deletions release-notes/v1.1.4.md
Original file line number Diff line number Diff line change
@@ -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
Loading