From 3574e98f782226698ce2c85eef1c737cf22f01df Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 16 Jun 2026 07:17:31 -0700 Subject: [PATCH] feat(menubar): customizable metric widgets in the status item (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fixed "42% · 5.2G" metrics string with a configurable, reorderable row of metric widgets, the way a desktop system monitor lets you pick what to glance at. - Store.menuBarItems: an ordered [MenuBarItem] (metric + style + colour), JSON-persisted. Defaults to the historical CPU + memory pair, and only ever shows in Metrics mode (which stays opt-in - the default is still the icon), so nothing changes for existing users until they customise. - MenuBarWidgets.swift (new): MenuBarMetric (CPU/RAM/GPU/disk/net/IO/fan/temp/battery), MenuBarWidgetStyle (value / label+value / bar / sparkline / speed-down-up / battery glyph), MenuBarColorMode (utilization/accent/mono/pressure), and MenuBarRenderer which draws the whole row into an NSImage via a drawing handler so monochrome text tracks the light/dark menu bar. Re-implemented in our own MIT Swift + Brand tokens. - StatusBarController renders the row from the live feed: a snapshot sink (CPU/RAM/GPU/disk/fan/temp/battery + cpu/mem/gpu sparkline rings) and a 1 Hz sink (live net/disk rates + their sparklines off the ring). Drawing is a few strings + shapes; it never walks the running-app list or does other blocking work on main - consistent with the hang fixes in #83. - SettingsView > Menu Bar gains a metrics editor (add / remove / reorder, per-metric style + colour pickers) shown when Display = Metrics; edits persist and re-render the status item live. Build: xcodebuild Debug succeeds, no new warnings. Satisfies #82. --- Sources/MenuBarWidgets.swift | 470 ++++++++++++++++++++++++++++++ Sources/SettingsView.swift | 98 ++++++- Sources/StatusBarController.swift | 104 ++++++- Sources/Store.swift | 18 +- 4 files changed, 673 insertions(+), 17 deletions(-) create mode 100644 Sources/MenuBarWidgets.swift diff --git a/Sources/MenuBarWidgets.swift b/Sources/MenuBarWidgets.swift new file mode 100644 index 00000000..96e62c1c --- /dev/null +++ b/Sources/MenuBarWidgets.swift @@ -0,0 +1,470 @@ +// +// MenuBarWidgets.swift +// Burrow +// +// The customizable menu-bar metric row (issue #82): the user picks an +// ordered set of metrics and a display style for each, and the status item +// renders them next to (or instead of) the Burrow mark. +// +// Model: +// * `MenuBarMetric` — what to show (CPU, RAM, GPU, disk, net, …). +// * `MenuBarWidgetStyle` — how to show it (value / label / bar / sparkline +// / speed / battery), à la a desktop monitor. +// * `MenuBarColorMode` — how to colour the value. +// * `MenuBarItem` — one configured widget (metric + style + colour), +// persisted via `Store.menuBarItems`. +// +// Rendering: `MenuBarRenderer` draws the whole row into an `NSImage` via a +// drawing handler (so it re-resolves dynamic colours for light/dark menu +// bars) which the controller hands to the status button. No custom hit- +// testing view — the button keeps its existing click/right-click action. +// All values arrive pre-computed on the main thread from the live feed; the +// drawing itself is cheap text + a few shapes (deliberately so — the app has +// a history of main-thread hangs; the menu bar must never add to that). +// + +import AppKit +import SwiftUI + +// MARK: - Model + +/// A metric that can be surfaced in the menu bar. +enum MenuBarMetric: String, Codable, CaseIterable, Identifiable { + case cpu, memory, gpu, diskUsage, network, diskIO, fan, temperature, battery + + var id: String { rawValue } + + /// Short uppercase tag used by the `labeled` style + the settings picker. + var label: String { + switch self { + case .cpu: return "CPU" + case .memory: return "RAM" + case .gpu: return "GPU" + case .diskUsage: return "DISK" + case .network: return "NET" + case .diskIO: return "I/O" + case .fan: return "FAN" + case .temperature: return "TEMP" + case .battery: return "BAT" + } + } + + /// Human name for the settings list. + var title: String { + switch self { + case .cpu: return NSLocalizedString("CPU usage", comment: "") + case .memory: return NSLocalizedString("Memory usage", comment: "") + case .gpu: return NSLocalizedString("GPU usage", comment: "") + case .diskUsage: return NSLocalizedString("Disk used", comment: "") + case .network: return NSLocalizedString("Network speed", comment: "") + case .diskIO: return NSLocalizedString("Disk I/O", comment: "") + case .fan: return NSLocalizedString("Fan speed", comment: "") + case .temperature: return NSLocalizedString("Temperature", comment: "") + case .battery: return NSLocalizedString("Battery", comment: "") + } + } + + /// SF Symbol for the settings list. + var glyph: String { + switch self { + case .cpu: return "cpu" + case .memory: return "memorychip" + case .gpu: return "display" + case .diskUsage: return "internaldrive" + case .network: return "network" + case .diskIO: return "arrow.up.arrow.down" + case .fan: return "fanblades" + case .temperature: return "thermometer.medium" + case .battery: return "battery.100" + } + } + + /// True for 0–100 metrics (drives bar/threshold colouring). + var isPercentage: Bool { + switch self { + case .cpu, .memory, .gpu, .diskUsage, .battery: return true + case .network, .diskIO, .fan, .temperature: return false + } + } + + /// Two-channel metrics (down/up, read/write) — eligible for the speed style. + var isDual: Bool { self == .network || self == .diskIO } + + /// Widget styles offered for this metric in the picker. + var styles: [MenuBarWidgetStyle] { + switch self { + case .cpu, .memory, .gpu: return [.value, .labeled, .bar, .sparkline] + case .diskUsage: return [.value, .labeled, .bar] + case .network, .diskIO: return [.value, .labeled, .speed, .sparkline] + case .fan, .temperature: return [.value, .labeled] + case .battery: return [.value, .labeled, .bar, .battery] + } + } +} + +/// How a metric is rendered in the bar. +enum MenuBarWidgetStyle: String, Codable, CaseIterable, Identifiable { + case value // "42%" + case labeled // "CPU 42%" + case bar // ▮▮▮▯ 42% + case sparkline // mini line chart + case speed // ↓12M ↑0.4M (two rows) + case battery // battery glyph + % + + var id: String { rawValue } + + var title: String { + switch self { + case .value: return NSLocalizedString("Value", comment: "") + case .labeled: return NSLocalizedString("Label + value", comment: "") + case .bar: return NSLocalizedString("Bar", comment: "") + case .sparkline: return NSLocalizedString("Sparkline", comment: "") + case .speed: return NSLocalizedString("Speed ↓↑", comment: "") + case .battery: return NSLocalizedString("Battery glyph", comment: "") + } + } +} + +/// How the value is coloured. +enum MenuBarColorMode: String, Codable, CaseIterable, Identifiable { + case utilization // green→gold→orange→red by load + case accent // Burrow blue + case mono // adapts to the menu bar (label colour) + case pressure // memory-pressure tinting + + var id: String { rawValue } + + var title: String { + switch self { + case .utilization: return NSLocalizedString("By utilization", comment: "") + case .accent: return NSLocalizedString("Accent", comment: "") + case .mono: return NSLocalizedString("Monochrome", comment: "") + case .pressure: return NSLocalizedString("By pressure", comment: "") + } + } +} + +/// One configured widget. `id` is stable across reorders/edits. +struct MenuBarItem: Codable, Equatable, Identifiable { + var id = UUID() + var metric: MenuBarMetric + var style: MenuBarWidgetStyle + var color: MenuBarColorMode = .utilization + + /// Coerce the style to one the metric actually supports (config can drift + /// if a metric's offerings change between versions). + var resolvedStyle: MenuBarWidgetStyle { + metric.styles.contains(style) ? style : (metric.styles.first ?? .value) + } + + /// Historical default: a compact CPU + memory pair. Only ever shown once a + /// user switches the menu bar to `.metrics` (default is the icon). + static let defaults: [MenuBarItem] = [ + MenuBarItem(metric: .cpu, style: .value), + MenuBarItem(metric: .memory, style: .value), + ] +} + +// MARK: - Values + +/// A main-thread snapshot of everything the row needs to draw, assembled from +/// the live feed by the controller. Optional = unavailable on this Mac (no +/// GPU util, no fans, no battery) → the widget shows "—". +struct MenuBarMetricValues { + /// Primary number per metric: percent, RPM, °C, or down/read MB/s. + var primary: [MenuBarMetric: Double] = [:] + /// Secondary channel for dual metrics: up/write MB/s. + var secondary: [MenuBarMetric: Double] = [:] + /// Sparkline series (oldest→newest), already downsampled by the controller. + var histories: [MenuBarMetric: [Double]] = [:] + var batteryCharging = false + + func has(_ m: MenuBarMetric) -> Bool { primary[m] != nil } +} + +// MARK: - Renderer + +/// Draws the configured widget row into an `NSImage`. Pure AppKit, Brand- +/// styled, sized to ~the menu-bar height. Re-runs its drawing handler per +/// appearance so monochrome text tracks the light/dark menu bar. +enum MenuBarRenderer { + static var height: CGFloat { max(18, NSStatusBar.system.thickness) } + + private static let spacing: CGFloat = 8 // between widgets + private static let pad: CGFloat = 3 // leading/trailing + private static let valueFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .medium) + private static let labelFont = NSFont.monospacedSystemFont(ofSize: 8, weight: .bold) + private static let speedFont = NSFont.monospacedDigitSystemFont(ofSize: 8, weight: .medium) + private static let barW: CGFloat = 22 + private static let barH: CGFloat = 4 + private static let sparkW: CGFloat = 26 + private static let batteryW: CGFloat = 19 + private static let batteryH: CGFloat = 10 + + /// Build the row image. Returns nil when there's nothing to draw (caller + /// then falls back to the icon). + static func image(items: [MenuBarItem], values: MenuBarMetricValues) -> NSImage? { + let cells = items.map { Cell(item: $0, values: values) } + guard !cells.isEmpty else { return nil } + let width = pad * 2 + cells.reduce(0) { $0 + $1.width } + spacing * CGFloat(cells.count - 1) + let h = height + let image = NSImage(size: NSSize(width: max(width, 1), height: h), flipped: false) { _ in + var x = pad + for cell in cells { + cell.draw(originX: x, height: h) + x += cell.width + spacing + } + return true + } + image.isTemplate = false + return image + } + + // MARK: Threshold colours + + private static func utilization(_ pct: Double) -> NSColor { + switch pct { + case 85...: return NSColor(Brand.red) + case 65..<85: return NSColor(Brand.orange) + case 40..<65: return NSColor(Brand.gold) + default: return NSColor(Brand.green) + } + } + + /// Battery is inverse: low charge is the alarming end. + private static func batteryColor(_ pct: Double, charging: Bool) -> NSColor { + if charging { return NSColor(Brand.green) } + switch pct { + case ..<10: return NSColor(Brand.red) + case 10..<25: return NSColor(Brand.orange) + default: return NSColor(Brand.green) + } + } + + static func color(for item: MenuBarItem, value: Double, values: MenuBarMetricValues) -> NSColor { + switch item.color { + case .mono: return .labelColor + case .accent: return NSColor(Brand.blue) + case .pressure, .utilization: + if item.metric == .battery { return batteryColor(value, charging: values.batteryCharging) } + if item.metric.isPercentage { return utilization(value) } + return NSColor(Brand.blue) + } + } + + // MARK: Formatting + + /// Compact rate: MB/s → "12M" / "1.2M" / "640K" / "0". + static func rate(_ mbs: Double) -> String { + if mbs >= 10 { return String(format: "%.0fM", mbs) } + if mbs >= 1 { return String(format: "%.1fM", mbs) } + let kb = mbs * 1024 + if kb >= 1 { return String(format: "%.0fK", kb) } + return "0" + } + + /// The single number a value/labeled/bar widget shows for a metric. + static func valueText(_ m: MenuBarMetric, _ values: MenuBarMetricValues) -> String { + guard let v = values.primary[m] else { return "—" } + switch m { + case .cpu, .memory, .gpu, .diskUsage, .battery: + return "\(Int(v.rounded()))%" + case .fan: + return v > 0 ? "\(Int(v.rounded()))" : "—" + case .temperature: + return "\(Int(v.rounded()))°" + case .network, .diskIO: + return rate(v + (values.secondary[m] ?? 0)) + } + } +} + +// MARK: - One drawn widget + +private struct Cell { + let item: MenuBarItem + let values: MenuBarMetricValues + let style: MenuBarWidgetStyle + let width: CGFloat + + init(item: MenuBarItem, values: MenuBarMetricValues) { + self.item = item + self.values = values + let style = item.resolvedStyle + self.style = style + self.width = Cell.width(item: item, style: style, values: values) + } + + // MARK: width + + private static func textW(_ s: String, _ font: NSFont) -> CGFloat { + (s as NSString).size(withAttributes: [.font: font]).width + } + + private static func width(item: MenuBarItem, style: MenuBarWidgetStyle, values: MenuBarMetricValues) -> CGFloat { + let valueW = textW(MenuBarRenderer.valueText(item.metric, values), MenuBarRenderer.valueFontPublic) + switch style { + case .value: + return valueW + case .labeled: + return textW(item.metric.label, MenuBarRenderer.labelFontPublic) + 4 + valueW + case .bar: + return MenuBarRenderer.barWPublic + 5 + valueW + case .sparkline: + return MenuBarRenderer.sparkWPublic + 5 + valueW + case .speed: + let rx = "↓" + MenuBarRenderer.rate(values.primary[item.metric] ?? 0) + let tx = "↑" + MenuBarRenderer.rate(values.secondary[item.metric] ?? 0) + return max(textW(rx, MenuBarRenderer.speedFontPublic), textW(tx, MenuBarRenderer.speedFontPublic)) + case .battery: + return MenuBarRenderer.batteryWPublic + 4 + valueW + } + } + + // MARK: draw + + func draw(originX: CGFloat, height: CGFloat) { + switch style { + case .value: drawValue(originX, height) + case .labeled: drawLabeled(originX, height) + case .bar: drawBar(originX, height) + case .sparkline: drawSparkline(originX, height) + case .speed: drawSpeed(originX, height) + case .battery: drawBattery(originX, height) + } + } + + private var valueColor: NSColor { + MenuBarRenderer.color(for: item, value: values.primary[item.metric] ?? 0, values: values) + } + + private func drawString(_ s: String, _ font: NSFont, _ color: NSColor, at p: NSPoint) { + (s as NSString).draw(at: p, withAttributes: [.font: font, .foregroundColor: color]) + } + + /// Baseline y that vertically centers `font` within the bar height. + private func centeredY(_ font: NSFont, _ height: CGFloat) -> CGFloat { + (height - font.ascender + font.descender) / 2 + } + + private func drawValue(_ x: CGFloat, _ h: CGFloat) { + let f = MenuBarRenderer.valueFontPublic + drawString(MenuBarRenderer.valueText(item.metric, values), f, valueColor, + at: NSPoint(x: x, y: centeredY(f, h))) + } + + private func drawLabeled(_ x: CGFloat, _ h: CGFloat) { + let lf = MenuBarRenderer.labelFontPublic + let label = item.metric.label + drawString(label, lf, NSColor.secondaryLabelColor, at: NSPoint(x: x, y: centeredY(lf, h))) + let lw = (label as NSString).size(withAttributes: [.font: lf]).width + let vf = MenuBarRenderer.valueFontPublic + drawString(MenuBarRenderer.valueText(item.metric, values), vf, valueColor, + at: NSPoint(x: x + lw + 4, y: centeredY(vf, h))) + } + + private func drawBar(_ x: CGFloat, _ h: CGFloat) { + let pct = max(0, min((values.primary[item.metric] ?? 0) / 100, 1)) + let bw = MenuBarRenderer.barWPublic, bh = MenuBarRenderer.barHPublic + let by = (h - bh) / 2 + let track = NSBezierPath(roundedRect: NSRect(x: x, y: by, width: bw, height: bh), + xRadius: bh / 2, yRadius: bh / 2) + NSColor(Brand.trackFill).setFill(); track.fill() + if pct > 0 { + let fill = NSBezierPath(roundedRect: NSRect(x: x, y: by, width: max(bh, bw * pct), height: bh), + xRadius: bh / 2, yRadius: bh / 2) + valueColor.setFill(); fill.fill() + } + let vf = MenuBarRenderer.valueFontPublic + drawString(MenuBarRenderer.valueText(item.metric, values), vf, valueColor, + at: NSPoint(x: x + bw + 5, y: centeredY(vf, h))) + } + + private func drawSparkline(_ x: CGFloat, _ h: CGFloat) { + let series = values.histories[item.metric] ?? [] + let sw = MenuBarRenderer.sparkWPublic + let inset: CGFloat = 4 + let chartH = h - inset * 2 + let color = valueColor + if series.count >= 2 { + let lo = series.min() ?? 0, hi = series.max() ?? 1 + let denom = max(hi - lo, item.metric.isPercentage ? 100 : 0.001) + let step = sw / CGFloat(series.count - 1) + func pt(_ i: Int) -> NSPoint { + let v = (series[i] - lo) / denom + return NSPoint(x: x + CGFloat(i) * step, y: inset + CGFloat(v) * chartH) + } + let line = NSBezierPath(); line.move(to: pt(0)) + for i in 1.. 0 { + let fill = NSBezierPath(rect: NSRect(x: x + inset, y: by + inset, width: fillW, height: bh - inset * 2)) + color.setFill(); fill.fill() + } + let vf = MenuBarRenderer.valueFontPublic + drawString(MenuBarRenderer.valueText(item.metric, values), vf, color, + at: NSPoint(x: x + bw + 4, y: centeredY(vf, h))) + } +} + +// MARK: - Layout-constant accessors +// +// `Cell` lives in this file but outside the `MenuBarRenderer` enum, so expose +// the private metrics it needs through thin public shims (keeps the tuning +// values in one place). +extension MenuBarRenderer { + static var valueFontPublic: NSFont { valueFont } + static var labelFontPublic: NSFont { labelFont } + static var speedFontPublic: NSFont { speedFont } + static var barWPublic: CGFloat { barW } + static var barHPublic: CGFloat { barH } + static var sparkWPublic: CGFloat { sparkW } + static var batteryWPublic: CGFloat { batteryW } + static var batteryHPublic: CGFloat { batteryH } +} diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index 23a0ab7e..b84d5c23 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -69,6 +69,7 @@ struct SettingsView: View { // Menu bar @State private var showMenuBarIcon: Bool = Store.showMenuBarIcon @State private var displayMode: MenuBarDisplayMode = Store.menuBarDisplayMode + @State private var menuBarItems: [MenuBarItem] = Store.menuBarItems @State private var inputLock: Bool = Store.cleanScreenInputLock @State private var axTrusted = CleanScreen.inputLockPermitted() @@ -384,7 +385,8 @@ struct SettingsView: View { AppDelegate.shared?.applyMenuBarVisibility(Store.showMenuBarIcon) } } - footnote("Metrics shows live CPU and memory next to the mark, refreshed with the sampler.") + footnote("Choose which metrics appear in the menu bar and how each is shown — refreshed with the sampler.") + if displayMode == .metrics { menuBarMetricsEditor } toggleRow("Show camera & mic in-use indicator", isOn: $cameraMicIndicator) { Store.cameraMicIndicatorEnabled = $0 } @@ -754,6 +756,100 @@ struct SettingsView: View { } } + // MARK: - Menu bar metrics editor (issue #82) + + /// Reorderable list of the metric widgets shown in `.metrics` mode, plus + /// an "Add metric" menu. Changes persist + re-render the status item live. + @ViewBuilder + private var menuBarMetricsEditor: some View { + VStack(spacing: 4) { + if menuBarItems.isEmpty { + Text(NSLocalizedString("No metrics yet — add one below.", comment: "")) + .font(Brand.sans(11)).foregroundStyle(Brand.textTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + ForEach(Array(menuBarItems.enumerated()), id: \.element.id) { idx, item in + menuBarMetricRow(index: idx, item: item) + } + HStack { + Menu { + ForEach(MenuBarMetric.allCases) { m in + Button { addMenuBarMetric(m) } label: { Label(m.title, systemImage: m.glyph) } + } + } label: { + Label(NSLocalizedString("Add metric", comment: ""), systemImage: "plus.circle") + .font(Brand.sans(12)).foregroundStyle(Brand.green) + } + .menuStyle(.borderlessButton).fixedSize() + Spacer() + } + .padding(.top, 2) + } + .padding(.vertical, 4) + } + + private func menuBarMetricRow(index idx: Int, item: MenuBarItem) -> some View { + HStack(spacing: 8) { + Image(systemName: item.metric.glyph).font(.system(size: 11)) + .foregroundStyle(Brand.textSecondary).frame(width: 16) + Text(item.metric.title).font(Brand.sans(12)).foregroundStyle(Brand.textPrimary) + Spacer(minLength: 6) + Menu { + ForEach(item.metric.styles) { st in + Button(st.title) { updateMenuBarItem(idx) { $0.style = st } } + } + } label: { + Text(item.resolvedStyle.title).font(Brand.mono(10)).foregroundStyle(Brand.textSecondary) + } + .menuStyle(.borderlessButton).fixedSize() + Menu { + ForEach(MenuBarColorMode.allCases) { c in + Button(c.title) { updateMenuBarItem(idx) { $0.color = c } } + } + } label: { + Image(systemName: "circle.lefthalf.filled").font(.system(size: 11)).foregroundStyle(Brand.textTertiary) + } + .menuStyle(.borderlessButton).fixedSize() + .help(NSLocalizedString("Color", comment: "")) + Button { moveMenuBarItem(idx, by: -1) } label: { + Image(systemName: "chevron.up").font(.system(size: 9, weight: .bold)).foregroundStyle(Brand.textTertiary) + } + .buttonStyle(.plain).disabled(idx == 0).accessibilityLabel(NSLocalizedString("Move up", comment: "")) + Button { moveMenuBarItem(idx, by: 1) } label: { + Image(systemName: "chevron.down").font(.system(size: 9, weight: .bold)).foregroundStyle(Brand.textTertiary) + } + .buttonStyle(.plain).disabled(idx == menuBarItems.count - 1).accessibilityLabel(NSLocalizedString("Move down", comment: "")) + Button { menuBarItems.remove(at: idx); commitMenuBarItems() } label: { + Image(systemName: "minus.circle.fill").font(.system(size: 12)).foregroundStyle(Brand.textTertiary) + } + .buttonStyle(.plain).accessibilityLabel(NSLocalizedString("Remove", comment: "")) + } + .padding(.vertical, 2) + } + + private func updateMenuBarItem(_ idx: Int, _ mutate: (inout MenuBarItem) -> Void) { + guard menuBarItems.indices.contains(idx) else { return } + mutate(&menuBarItems[idx]); commitMenuBarItems() + } + + private func addMenuBarMetric(_ m: MenuBarMetric) { + menuBarItems.append(MenuBarItem(metric: m, style: m.styles.first ?? .value)) + commitMenuBarItems() + } + + private func moveMenuBarItem(_ idx: Int, by delta: Int) { + let j = idx + delta + guard menuBarItems.indices.contains(j) else { return } + menuBarItems.swapAt(idx, j); commitMenuBarItems() + } + + /// Persist + re-render the status item (applyMenuBarVisibility re-runs the + /// display mode when the icon is shown). + private func commitMenuBarItems() { + Store.menuBarItems = menuBarItems + AppDelegate.shared?.applyMenuBarVisibility(Store.showMenuBarIcon) + } + // MARK: - Status labels private func refreshStatusLabels() { diff --git a/Sources/StatusBarController.swift b/Sources/StatusBarController.swift index c2cb7939..b6a42620 100644 --- a/Sources/StatusBarController.swift +++ b/Sources/StatusBarController.swift @@ -24,6 +24,13 @@ final class StatusBarController: NSObject, NSMenuDelegate { private let producer: SnapshotProducer private weak var delegate: AppDelegate? private var metricsSub: AnyCancellable? + /// 1 Hz net/disk-rate updates for the metrics row (separate from the + /// snapshot sink so live throughput animates between snapshots). + private var samplesSub: AnyCancellable? + /// Rolling per-metric history for the menu-bar sparkline style, appended + /// at the snapshot cadence (net/disk sparklines read straight off the + /// live ring instead). + private var menuBarHistory: [MenuBarMetric: [Double]] = [:] /// A small accent dot shown over the glyph when a Burrow self-update is /// available (driven by AppUpdate via .burrowUpdateAvailability). private let updateDot = NSView() @@ -86,29 +93,96 @@ final class StatusBarController: NSObject, NSMenuDelegate { } } - /// Icon vs Metrics (Settings ▸ Menu Bar): metrics renders live CPU% + - /// memory next to the mark, refreshed as snapshots arrive. + /// Icon vs Metrics (Settings ▸ Menu Bar). Metrics renders the user's + /// configured `Store.menuBarItems` row (issue #82); icon shows the mark. + /// Safe to call again to apply a settings change live. func applyDisplayMode() { guard let button = item.button else { return } - if Store.menuBarDisplayMode == .metrics { - button.imagePosition = .imageLeft - metricsSub = producer.live.$lastSnapshot - .receive(on: DispatchQueue.main) - .sink { [weak self] snapshot in - guard let button = self?.item.button, let s = snapshot else { return } - let mem = Double(s.memory.used) / 1_073_741_824 - button.title = String(format: " %.0f%% · %.1fG", s.cpu.usage, mem) - button.font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .medium) - self?.refreshUpdateDot() // width changed → reposition - } - } else { + guard Store.menuBarDisplayMode == .metrics else { metricsSub = nil - button.title = "" + samplesSub = nil + menuBarHistory.removeAll() + button.image = BurrowIcons.menuBar button.imagePosition = .imageOnly + button.title = "" + refreshUpdateDot() + return + } + // Snapshot sink: CPU/RAM/GPU/disk/fan/temp/battery + sparkline history. + metricsSub = producer.live.$lastSnapshot + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.onSnapshot() } + // 1 Hz sink: live net/disk rates + their sparklines. + samplesSub = producer.live.$samples + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.renderMetrics() } + renderMetrics() + refreshUpdateDot() + } + + /// Snapshot tick: extend the cpu/mem/gpu sparkline rings (only the snapshot + /// carries those series), then redraw. + private func onSnapshot() { + guard Store.menuBarDisplayMode == .metrics, let s = producer.live.lastSnapshot else { return } + appendHistory(.cpu, s.cpu.usage) + appendHistory(.memory, s.memory.usedPercent) + if let g = s.gpu?.first, g.usage >= 0 { appendHistory(.gpu, g.usage) } + renderMetrics() + } + + private func appendHistory(_ m: MenuBarMetric, _ v: Double) { + var ring = menuBarHistory[m] ?? [] + ring.append(v) + if ring.count > 40 { ring.removeFirst(ring.count - 40) } + menuBarHistory[m] = ring + } + + /// Assemble the current values and draw the row into the status button. + /// Deliberately cheap (a few strings + shapes) and reads only already- + /// published live values — never the running-app list or other blocking + /// work, so the menu bar can't add to the main-thread budget. + private func renderMetrics() { + guard let button = item.button, Store.menuBarDisplayMode == .metrics else { return } + if let image = MenuBarRenderer.image(items: Store.menuBarItems, values: currentValues()) { + button.image = image + } else { + button.image = BurrowIcons.menuBar } + button.imagePosition = .imageOnly + button.title = "" refreshUpdateDot() } + /// Snapshot of every metric the row might draw, read on the main thread + /// from the live feed (which is itself main-thread-confined). + private func currentValues() -> MenuBarMetricValues { + var v = MenuBarMetricValues() + let live = producer.live + if let s = live.lastSnapshot { + v.primary[.cpu] = s.cpu.usage + v.primary[.memory] = s.memory.usedPercent + if let g = s.gpu?.first, g.usage >= 0 { v.primary[.gpu] = g.usage } + if let d = s.disks.first { v.primary[.diskUsage] = d.usedPercent } + if let t = s.thermal { + if t.fanSpeed > 0 || (t.fanCount ?? 0) > 0 { v.primary[.fan] = Double(t.fanSpeed) } + if let temp = t.bestTemp { v.primary[.temperature] = temp } + } + if let b = s.batteries?.first { + v.primary[.battery] = b.percent + v.batteryCharging = b.status.lowercased().contains("charg") + } + } + v.primary[.network] = live.rxMBs; v.secondary[.network] = live.txMBs + v.primary[.diskIO] = live.readMBs; v.secondary[.diskIO] = live.writeMBs + let recent = live.samples.suffix(30) + v.histories[.network] = recent.map { $0.rxMBs + $0.txMBs } + v.histories[.diskIO] = recent.map { $0.readMBs + $0.writeMBs } + v.histories[.cpu] = menuBarHistory[.cpu] + v.histories[.memory] = menuBarHistory[.memory] + v.histories[.gpu] = menuBarHistory[.gpu] + return v + } + deinit { if let o = updateObserver { NotificationCenter.default.removeObserver(o) } // Explicitly remove the item so toggling the menu-bar setting off diff --git a/Sources/Store.swift b/Sources/Store.swift index d975467e..a198fe10 100644 --- a/Sources/Store.swift +++ b/Sources/Store.swift @@ -315,12 +315,28 @@ enum Store { set { write(newValue.rawValue, "cache_removal_mode") } } - /// What the status item shows: the Burrow mark, or live text metrics. + /// What the status item shows: the Burrow mark, or live metrics. static var menuBarDisplayMode: MenuBarDisplayMode { get { MenuBarDisplayMode(rawValue: d.string(forKey: "menu_bar_display_mode") ?? "") ?? .icon } set { write(newValue.rawValue, "menu_bar_display_mode") } } + /// The ordered set of metric widgets the status item renders in + /// `.metrics` mode (see `MenuBarItem` / `MenuBarWidgets.swift`). Persisted + /// as JSON so the shape can grow without new keys. Falls back to the + /// historical CPU + memory pair, so users who already chose "metrics" see + /// no change until they customize. + static var menuBarItems: [MenuBarItem] { + get { + guard let data = d.data(forKey: "menu_bar_items"), + let items = try? JSONDecoder().decode([MenuBarItem].self, from: data), + !items.isEmpty + else { return MenuBarItem.defaults } + return items + } + set { write(try? JSONEncoder().encode(newValue), "menu_bar_items") } + } + /// Whether closing the last window drops the Dock icon (the classic /// menu-bar-agent behavior, on by default). Off keeps Burrow in the /// Dock permanently. The safety inversion stays: with the menu-bar