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 1/2] 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 From 32ab0c0d8f5bd77e4d8bfaf2a17ae2757ae21991 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Wed, 17 Jun 2026 03:14:11 -0700 Subject: [PATCH 2/2] feat(menubar): customizable popup, Stats-depth widgets, RunCat-style runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on #84 after hands-on testing: - Popup (#82, the real ask): Store.popupSections/popupTiles gate PopupView's sections + the six metric tiles; a 'Popup contents' settings section toggles them. - Widgets: MenuBarItem gains label/value/fill/history/pictogram/units + a wider colour palette (tolerant decode keeps old configs); the editor is now an expandable per-widget options panel (desktop-monitor-style depth). - Runner: new MenuBarRunner draws original built-in animations + decodes a custom GIF (ImageIO); playback speed tracks a chosen metric. StatusBarController animates it standalone (.runner mode) or prepended to the widget row (row cached between frames so it stays hang-safe). 'Animated icon' settings section + GIF import (NSOpenPanel wrapped in withoutAppHangTracking). No third-party runner artwork bundled — the mechanism is re-implemented (MIT). --- Sources/MenuBarRunner.swift | 199 +++++++++++++++++++++++++ Sources/MenuBarWidgets.swift | 195 +++++++++++++++++++----- Sources/PopupView.swift | 82 +++++----- Sources/SettingsView.swift | 238 +++++++++++++++++++++++++++--- Sources/StatusBarController.swift | 153 ++++++++++++++++--- Sources/Store.swift | 60 +++++++- 6 files changed, 807 insertions(+), 120 deletions(-) create mode 100644 Sources/MenuBarRunner.swift diff --git a/Sources/MenuBarRunner.swift b/Sources/MenuBarRunner.swift new file mode 100644 index 00000000..d3f7c201 --- /dev/null +++ b/Sources/MenuBarRunner.swift @@ -0,0 +1,199 @@ +// +// MenuBarRunner.swift +// Burrow +// +// A RunCat-style animated menu-bar icon: an icon whose playback speed tracks +// a chosen metric (faster = busier). The *mechanism* — cycle frame images on +// the status button via a timer, scaling the interval by load — is +// re-implemented from scratch (Burrow is MIT); no third-party runner artwork +// is bundled. The built-in runners are drawn programmatically; users import +// their own GIF for anything fancier. +// +// Model lives here (persisted via `Store.runnerConfig`); the actual timer + +// button image is driven by `StatusBarController`. +// + +import AppKit +import ImageIO +import UniformTypeIdentifiers + +// MARK: - Config + +/// Where the runner's frames come from. +enum RunnerSource: Codable, Equatable { + case builtIn(String) // a `RunnerCatalog` id + case gif(String) // absolute path to a GIF imported into App Support +} + +/// Persisted runner appearance. Whether the runner shows at all is the menu- +/// bar display mode (`.runner` = its own; `.metrics` + `prependToRow` = before +/// the widget row). +struct RunnerConfig: Codable, Equatable { + var source: RunnerSource = .builtIn(RunnerCatalog.defaultID) + /// The metric whose usage sets the animation speed. + var metric: MenuBarMetric = .cpu + /// Show the metric's value next to the runner. + var showValue = false + /// 0.5…2.0 — higher swings the speed more between idle and busy. + var sensitivity: Double = 1.0 + /// In `.metrics` mode, animate the runner before the widget row. + var prependToRow = false +} + +// MARK: - Frames + +/// A decoded animation: frames already sized to the menu-bar height. +struct RunnerFrames { + let frames: [NSImage] + var count: Int { frames.count } + func frame(_ i: Int) -> NSImage? { frames.isEmpty ? nil : frames[i % frames.count] } +} + +// MARK: - Built-in catalog (original, drawn programmatically — no bundled art) + +enum RunnerCatalog { + static let defaultID = "pulse" + + struct Builtin: Identifiable { let id: String; let title: String } + static let all: [Builtin] = [ + .init(id: "pulse", title: NSLocalizedString("Pulse", comment: "")), + .init(id: "orbit", title: NSLocalizedString("Orbit", comment: "")), + .init(id: "stride", title: NSLocalizedString("Stride", comment: "")), + ] + + /// Frames for a built-in runner at the given height (square frames). + static func frames(id: String, height: CGFloat) -> RunnerFrames { + let n = 12 + let images = (0.. NSImage { + let s = max(16, side) + return NSImage(size: NSSize(width: s, height: s), flipped: false) { rect in + let c = NSColor.labelColor + let mid = NSPoint(x: rect.midX, y: rect.midY) + switch id { + case "orbit": + let r = rect.width * 0.30 + let ring = NSBezierPath(ovalIn: NSRect(x: mid.x - r, y: mid.y - r, width: r * 2, height: r * 2)) + c.withAlphaComponent(0.25).setStroke(); ring.lineWidth = 1.5; ring.stroke() + let a = phase * 2 * .pi + let p = NSPoint(x: mid.x + cos(a) * r, y: mid.y + sin(a) * r) + let dot = NSBezierPath(ovalIn: NSRect(x: p.x - 2, y: p.y - 2, width: 4, height: 4)) + c.setFill(); dot.fill() + case "stride": + let swing = sin(phase * 2 * .pi) * rect.width * 0.18 + for sgn in [CGFloat(-1), 1] { + let leg = NSBezierPath() + leg.move(to: NSPoint(x: mid.x, y: rect.midY + rect.height * 0.10)) + leg.line(to: NSPoint(x: mid.x + sgn * swing, y: rect.minY + rect.height * 0.20)) + leg.lineWidth = 2; leg.lineCapStyle = .round; c.setStroke(); leg.stroke() + } + let body = NSBezierPath(ovalIn: NSRect(x: mid.x - 3, y: rect.midY + rect.height * 0.10, width: 6, height: 6)) + c.setFill(); body.fill() + default: // "pulse" — concentric expanding rings + for k in 0..<3 { + let t = (phase + CGFloat(k) / 3).truncatingRemainder(dividingBy: 1) + let r = rect.width * 0.10 + t * rect.width * 0.30 + let ring = NSBezierPath(ovalIn: NSRect(x: mid.x - r, y: mid.y - r, width: r * 2, height: r * 2)) + c.withAlphaComponent(Double(1 - t) * 0.8).setStroke(); ring.lineWidth = 1.5; ring.stroke() + } + } + return true + } + } +} + +// MARK: - GIF decode + import + +enum RunnerGIF { + /// Decode a GIF into frames resized to `height`. nil on failure. + /// MUST run off the main thread (disk read + decode). + static func decode(path: String, height: CGFloat) -> RunnerFrames? { + let url = URL(fileURLWithPath: path) + guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let n = CGImageSourceGetCount(src) + guard n > 0 else { return nil } + var out: [NSImage] = [] + out.reserveCapacity(n) + for i in 0.. NSImage { + let scale = height / max(image.size.height, 1) + let size = NSSize(width: max(1, image.size.width * scale), height: height) + let out = NSImage(size: size) + out.lockFocus() + image.draw(in: NSRect(origin: .zero, size: size), + from: NSRect(origin: .zero, size: image.size), + operation: .sourceOver, fraction: 1) + out.unlockFocus() + return out + } + + /// Copy an imported GIF into Application Support (so it survives the + /// original moving/deleting), returning the stored path. + static func importGIF(from url: URL) -> String? { + let fm = FileManager.default + guard let support = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } + let dir = support.appendingPathComponent("Burrow/runners", isDirectory: true) + try? fm.createDirectory(at: dir, withIntermediateDirectories: true) + let dest = dir.appendingPathComponent("runner.gif") + try? fm.removeItem(at: dest) + do { try fm.copyItem(at: url, to: dest); return dest.path } catch { return nil } + } +} + +// MARK: - Engine: frames + speed mapping + +/// Holds the runner's frames + the advancing index, and maps a metric value to +/// the frame interval. `StatusBarController` owns the timer and the button. +final class RunnerEngine { + private(set) var frames = RunnerFrames(frames: []) + private var index = 0 + private var config = RunnerConfig() + + var hasFrames: Bool { frames.count > 0 } + + /// (Re)load frames for `cfg`. Built-ins are synchronous; a GIF decodes + /// off-main and lands its frames on the main thread before `completion`. + func reload(_ cfg: RunnerConfig, height: CGFloat, completion: @escaping () -> Void) { + config = cfg + index = 0 + switch cfg.source { + case .builtIn(let id): + frames = RunnerCatalog.frames(id: id, height: height) + completion() + case .gif(let path): + DispatchQueue.global(qos: .userInitiated).async { + let f = RunnerGIF.decode(path: path, height: height) + ?? RunnerCatalog.frames(id: RunnerCatalog.defaultID, height: height) + DispatchQueue.main.async { self.frames = f; completion() } + } + } + } + + /// Advance to and return the next frame (nil if there are none). + func nextFrame() -> NSImage? { + guard frames.count > 0 else { return nil } + defer { index = (index + 1) % frames.count } + return frames.frame(index) + } + + /// Frame interval (seconds) for a metric value in 0…100. Faster when + /// busier, clamped so idle isn't frozen and busy isn't a blur. + func interval(forUsage usage: Double) -> TimeInterval { + let u = max(0, min(usage, 100)) / 100 + let base: TimeInterval = 0.20 // idle + let fastest: TimeInterval = 0.05 // pinned + let swing = (base - fastest) * config.sensitivity + return max(fastest, base - swing * u) + } +} diff --git a/Sources/MenuBarWidgets.swift b/Sources/MenuBarWidgets.swift index 96e62c1c..bb76cba2 100644 --- a/Sources/MenuBarWidgets.swift +++ b/Sources/MenuBarWidgets.swift @@ -100,6 +100,10 @@ enum MenuBarMetric: String, Codable, CaseIterable, Identifiable { case .battery: return [.value, .labeled, .bar, .battery] } } + + /// The metrics offered as tiles in the popover grid (issue #82 popup + /// customization), in display order. + static let popupGrid: [MenuBarMetric] = [.cpu, .gpu, .memory, .diskUsage, .network, .fan] } /// How a metric is rendered in the bar. @@ -125,12 +129,15 @@ enum MenuBarWidgetStyle: String, Codable, CaseIterable, Identifiable { } } -/// How the value is coloured. +/// How the value is coloured. The first four are value-driven; the rest are +/// fixed named colours from the Brand palette (à la a desktop monitor's +/// per-widget colour picker). 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 + case blue, green, orange, gold, amber, red var id: String { rawValue } @@ -140,16 +147,99 @@ enum MenuBarColorMode: String, Codable, CaseIterable, Identifiable { case .accent: return NSLocalizedString("Accent", comment: "") case .mono: return NSLocalizedString("Monochrome", comment: "") case .pressure: return NSLocalizedString("By pressure", comment: "") + case .blue: return NSLocalizedString("Blue", comment: "") + case .green: return NSLocalizedString("Green", comment: "") + case .orange: return NSLocalizedString("Orange", comment: "") + case .gold: return NSLocalizedString("Gold", comment: "") + case .amber: return NSLocalizedString("Amber", comment: "") + case .red: return NSLocalizedString("Red", comment: "") + } + } + + /// A fixed colour for the named modes; nil for the value-driven ones. + var fixedColor: NSColor? { + switch self { + case .blue: return NSColor(Brand.blue) + case .green: return NSColor(Brand.green) + case .orange: return NSColor(Brand.orange) + case .gold: return NSColor(Brand.gold) + case .amber: return NSColor(Brand.amber) + case .red: return NSColor(Brand.red) + default: return nil + } + } +} + +/// The up/down indicator the `speed` style draws. +enum SpeedPictogram: String, Codable, CaseIterable, Identifiable { + case arrows, dots, none + var id: String { rawValue } + var title: String { + switch self { + case .arrows: return NSLocalizedString("Arrows", comment: "") + case .dots: return NSLocalizedString("Dots", comment: "") + case .none: return NSLocalizedString("None", comment: "") + } + } + func symbol(up: Bool) -> String { + switch self { + case .arrows: return up ? "↑" : "↓" + case .dots: return "•" + case .none: return "" } } } -/// One configured widget. `id` is stable across reorders/edits. +/// One configured widget. `id` is stable across reorders/edits. The extra +/// per-widget options (label/value/fill/history/pictogram/units) give the +/// depth a desktop monitor's widget settings offer. struct MenuBarItem: Codable, Equatable, Identifiable { var id = UUID() var metric: MenuBarMetric var style: MenuBarWidgetStyle var color: MenuBarColorMode = .utilization + /// Prefix the metric's short label (e.g. "CPU") on the bar/sparkline/ + /// speed/battery styles (the `.labeled` style already carries one). + var showLabel: Bool = false + /// Draw the trailing numeric on the bar/sparkline/speed/battery styles. + var showValue: Bool = true + /// Sparkline: filled area vs. a bare stroke. + var fill: Bool = true + /// Sparkline: how many recent points to plot (30/60/90/120). + var historyPoints: Int = 30 + /// Speed: the up/down indicator. + var pictogram: SpeedPictogram = .arrows + /// Speed: append the unit suffix (M/K) to the rate. + var showUnits: Bool = true + + init(metric: MenuBarMetric, style: MenuBarWidgetStyle, color: MenuBarColorMode = .utilization, + showLabel: Bool = false, showValue: Bool = true, fill: Bool = true, + historyPoints: Int = 30, pictogram: SpeedPictogram = .arrows, showUnits: Bool = true) { + self.metric = metric; self.style = style; self.color = color + self.showLabel = showLabel; self.showValue = showValue; self.fill = fill + self.historyPoints = historyPoints; self.pictogram = pictogram; self.showUnits = showUnits + } + + enum CodingKeys: String, CodingKey { + case id, metric, style, color, showLabel, showValue, fill, historyPoints, pictogram, showUnits + } + + /// Tolerant decode: every field falls back to its default, so widget rows + /// persisted by an earlier version (which only stored id/metric/style/ + /// colour) still load instead of dropping the user's whole row set. + init(from d: Decoder) throws { + let c = try d.container(keyedBy: CodingKeys.self) + id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID() + metric = (try? c.decode(MenuBarMetric.self, forKey: .metric)) ?? .cpu + style = (try? c.decode(MenuBarWidgetStyle.self, forKey: .style)) ?? .value + color = (try? c.decode(MenuBarColorMode.self, forKey: .color)) ?? .utilization + showLabel = (try? c.decode(Bool.self, forKey: .showLabel)) ?? false + showValue = (try? c.decode(Bool.self, forKey: .showValue)) ?? true + fill = (try? c.decode(Bool.self, forKey: .fill)) ?? true + historyPoints = (try? c.decode(Int.self, forKey: .historyPoints)) ?? 30 + pictogram = (try? c.decode(SpeedPictogram.self, forKey: .pictogram)) ?? .arrows + showUnits = (try? c.decode(Bool.self, forKey: .showUnits)) ?? true + } /// Coerce the style to one the metric actually supports (config can drift /// if a metric's offerings change between versions). @@ -242,6 +332,7 @@ enum MenuBarRenderer { } static func color(for item: MenuBarItem, value: Double, values: MenuBarMetricValues) -> NSColor { + if let fixed = item.color.fixedColor { return fixed } // named colours switch item.color { case .mono: return .labelColor case .accent: return NSColor(Brand.blue) @@ -249,17 +340,19 @@ enum MenuBarRenderer { if item.metric == .battery { return batteryColor(value, charging: values.batteryCharging) } if item.metric.isPercentage { return utilization(value) } return NSColor(Brand.blue) + default: return .labelColor // unreachable: named modes handled above } } // 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) } + /// Compact rate: MB/s → "12M" / "1.2M" / "640K" / "0". `units:false` drops + /// the M/K suffix (Speed widget's "Units" toggle). + static func rate(_ mbs: Double, units: Bool = true) -> String { + if mbs >= 10 { return String(format: units ? "%.0fM" : "%.0f", mbs) } + if mbs >= 1 { return String(format: units ? "%.1fM" : "%.1f", mbs) } let kb = mbs * 1024 - if kb >= 1 { return String(format: "%.0fK", kb) } + if kb >= 1 { return String(format: units ? "%.0fK" : "%.0f", kb) } return "0" } @@ -303,34 +396,46 @@ private struct Cell { private static func width(item: MenuBarItem, style: MenuBarWidgetStyle, values: MenuBarMetricValues) -> CGFloat { let valueW = textW(MenuBarRenderer.valueText(item.metric, values), MenuBarRenderer.valueFontPublic) + // Optional leading label (on the non-text styles) + optional trailing value. + let labelPrefix = (item.showLabel && style != .value && style != .labeled) + ? textW(item.metric.label, MenuBarRenderer.labelFontPublic) + 4 : 0 + let valueSuffix = item.showValue ? (5 + valueW) : 0 switch style { case .value: return valueW case .labeled: return textW(item.metric.label, MenuBarRenderer.labelFontPublic) + 4 + valueW case .bar: - return MenuBarRenderer.barWPublic + 5 + valueW + return labelPrefix + MenuBarRenderer.barWPublic + valueSuffix case .sparkline: - return MenuBarRenderer.sparkWPublic + 5 + valueW + return labelPrefix + MenuBarRenderer.sparkWPublic + valueSuffix 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)) + let rx = item.pictogram.symbol(up: false) + MenuBarRenderer.rate(values.primary[item.metric] ?? 0, units: item.showUnits) + let tx = item.pictogram.symbol(up: true) + MenuBarRenderer.rate(values.secondary[item.metric] ?? 0, units: item.showUnits) + return labelPrefix + max(textW(rx, MenuBarRenderer.speedFontPublic), textW(tx, MenuBarRenderer.speedFontPublic)) case .battery: - return MenuBarRenderer.batteryWPublic + 4 + valueW + return labelPrefix + MenuBarRenderer.batteryWPublic + valueSuffix } } // MARK: draw func draw(originX: CGFloat, height: CGFloat) { + var x = originX + // Optional leading label for the non-text styles (value/labeled draw + // their own label inline). + if item.showLabel, style != .value, style != .labeled { + let lf = MenuBarRenderer.labelFontPublic + drawString(item.metric.label, lf, .secondaryLabelColor, at: NSPoint(x: x, y: centeredY(lf, height))) + x += (item.metric.label as NSString).size(withAttributes: [.font: lf]).width + 4 + } 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) + case .value: drawValue(x, height) + case .labeled: drawLabeled(x, height) + case .bar: drawBar(x, height) + case .sparkline: drawSparkline(x, height) + case .speed: drawSpeed(x, height) + case .battery: drawBattery(x, height) } } @@ -375,13 +480,15 @@ private struct Cell { 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))) + if item.showValue { + 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 series = Array((values.histories[item.metric] ?? []).suffix(item.historyPoints)) let sw = MenuBarRenderer.sparkWPublic let inset: CGFloat = 4 let chartH = h - inset * 2 @@ -396,12 +503,14 @@ private struct Cell { } let line = NSBezierPath(); line.move(to: pt(0)) for i in 1.. some View { - LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)], spacing: 8) { - ValueTile(variant: .hud, eyebrow: "CPU", glyph: "cpu", accent: Brand.green, - value: String(format: "%.0f", s.cpu.usage), unit: "%", - chip: (s.thermal?.cpuTemp).flatMap { $0 > 0 ? (String(format: "%.0f°C", $0), Brand.orange) : nil }, - values: model.cpuHist, chartStyle: .bars, - footnote: String(format: "load %.2f", s.cpu.load1)) - ValueTile(variant: .hud, eyebrow: "GPU", glyph: "cpu.fill", accent: Brand.orange, - value: gpuValue(s).0, unit: gpuValue(s).1, - chip: (s.thermal?.gpuTemp).flatMap { $0 > 0 ? (String(format: "%.0f°C", $0), Brand.orange) : nil }, - values: model.gpuHist, chartStyle: .bars, - footnote: (s.gpu?.first?.name ?? "GPU").replacingOccurrences(of: "Apple ", with: "")) - ValueTile(variant: .hud, eyebrow: "Memory", glyph: "memorychip", accent: Brand.amber, - value: String(format: "%.0f", s.memory.usedPercent), unit: "%", - chip: memChip(s), - values: model.memHist, chartStyle: .area, - footnote: String(format: "%.1f/%.0f GB · swap %.1f GB", - Fmt.gib(s.memory.used), Fmt.gib(s.memory.total), Fmt.gib(s.memory.swapUsed))) - diskTile(s) - ValueTile(variant: .hud, eyebrow: "Network", glyph: "network", accent: Brand.green, - value: netValue(s).0, unit: netValue(s).1, - chip: (s.network.first(where: { !$0.ip.isEmpty })?.name).map { ($0, Brand.blue) }, - values: model.netHist, chartStyle: .area, - footnote: netFoot(s)) - fanTile(s) + let tiles = Store.popupTiles // which tiles the user wants (issue #82) + return LazyVGrid(columns: [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)], spacing: 8) { + if tiles.contains(.cpu) { + ValueTile(variant: .hud, eyebrow: "CPU", glyph: "cpu", accent: Brand.green, + value: String(format: "%.0f", s.cpu.usage), unit: "%", + chip: (s.thermal?.cpuTemp).flatMap { $0 > 0 ? (String(format: "%.0f°C", $0), Brand.orange) : nil }, + values: model.cpuHist, chartStyle: .bars, + footnote: String(format: "load %.2f", s.cpu.load1)) + } + if tiles.contains(.gpu) { + ValueTile(variant: .hud, eyebrow: "GPU", glyph: "cpu.fill", accent: Brand.orange, + value: gpuValue(s).0, unit: gpuValue(s).1, + chip: (s.thermal?.gpuTemp).flatMap { $0 > 0 ? (String(format: "%.0f°C", $0), Brand.orange) : nil }, + values: model.gpuHist, chartStyle: .bars, + footnote: (s.gpu?.first?.name ?? "GPU").replacingOccurrences(of: "Apple ", with: "")) + } + if tiles.contains(.memory) { + ValueTile(variant: .hud, eyebrow: "Memory", glyph: "memorychip", accent: Brand.amber, + value: String(format: "%.0f", s.memory.usedPercent), unit: "%", + chip: memChip(s), + values: model.memHist, chartStyle: .area, + footnote: String(format: "%.1f/%.0f GB · swap %.1f GB", + Fmt.gib(s.memory.used), Fmt.gib(s.memory.total), Fmt.gib(s.memory.swapUsed))) + } + if tiles.contains(.diskUsage) { diskTile(s) } + if tiles.contains(.network) { + ValueTile(variant: .hud, eyebrow: "Network", glyph: "network", accent: Brand.green, + value: netValue(s).0, unit: netValue(s).1, + chip: (s.network.first(where: { !$0.ip.isEmpty })?.name).map { ($0, Brand.blue) }, + values: model.netHist, chartStyle: .area, + footnote: netFoot(s)) + } + if tiles.contains(.fan) { fanTile(s) } } } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index b84d5c23..cbfcd1c5 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -18,6 +18,7 @@ import SwiftUI import AppKit import LocalAuthentication import ServiceManagement +import UniformTypeIdentifiers struct SettingsView: View { enum Tab: String, CaseIterable, Identifiable { @@ -70,6 +71,11 @@ struct SettingsView: View { @State private var showMenuBarIcon: Bool = Store.showMenuBarIcon @State private var displayMode: MenuBarDisplayMode = Store.menuBarDisplayMode @State private var menuBarItems: [MenuBarItem] = Store.menuBarItems + /// Which widget's options panel is expanded in the editor (one at a time). + @State private var expandedMenuBarItem: UUID? + @State private var popupSections: Set = Store.popupSections + @State private var popupTiles: Set = Store.popupTiles + @State private var runnerConfig: RunnerConfig = Store.runnerConfig @State private var inputLock: Bool = Store.cleanScreenInputLock @State private var axTrusted = CleanScreen.inputLockPermitted() @@ -378,8 +384,9 @@ struct SettingsView: View { Picker("", selection: $displayMode) { Text(NSLocalizedString("Icon", comment: "")).tag(MenuBarDisplayMode.icon) Text(NSLocalizedString("Metrics", comment: "")).tag(MenuBarDisplayMode.metrics) + Text(NSLocalizedString("Runner", comment: "")).tag(MenuBarDisplayMode.runner) } - .labelsHidden().pickerStyle(.segmented).frame(width: 200) + .labelsHidden().pickerStyle(.segmented).frame(width: 240) .onChange(of: displayMode) { _, v in Store.menuBarDisplayMode = v AppDelegate.shared?.applyMenuBarVisibility(Store.showMenuBarIcon) @@ -393,6 +400,16 @@ struct SettingsView: View { footnote("A small \u{201C}in use\u{201D} chip in the popover when the camera or microphone is active — the same system signal as Control Center, so it also lights for Siri, dictation and Continuity Camera. Off by default.") } + section("Animated icon", "hare") { + footnote("A RunCat-style animated icon whose speed tracks a metric. Pick \u{201C}Runner\u{201D} above to show it on its own, or turn on \u{201C}Before widgets\u{201D} to animate it ahead of the metric row.") + runnerEditor + } + + section("Popup contents", "macwindow") { + footnote("Choose which sections and metric tiles the menu-bar popover shows.") + popupContentsEditor + } + section("Keyboard shortcuts", "keyboard") { shortcutRow("Open Burrow", action: .openBurrow) shortcutRow("Keep Screen On", action: .keepScreenOn) @@ -758,7 +775,8 @@ struct SettingsView: View { // MARK: - Menu bar metrics editor (issue #82) - /// Reorderable list of the metric widgets shown in `.metrics` mode, plus + /// Reorderable list of widgets, each expandable into its full options + /// (style, colour, label/value toggles, and style-specific extras), plus /// an "Add metric" menu. Changes persist + re-render the status item live. @ViewBuilder private var menuBarMetricsEditor: some View { @@ -769,7 +787,15 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } ForEach(Array(menuBarItems.enumerated()), id: \.element.id) { idx, item in - menuBarMetricRow(index: idx, item: item) + VStack(spacing: 6) { + menuBarMetricRow(index: idx, item: item) + if expandedMenuBarItem == item.id { + menuBarItemOptions(index: idx, item: item) + } + } + .padding(6) + .background(RoundedRectangle(cornerRadius: 8) + .fill(expandedMenuBarItem == item.id ? Brand.cardFill : Color.clear)) } HStack { Menu { @@ -793,24 +819,8 @@ struct SettingsView: View { 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) + Text(item.resolvedStyle.title).font(Brand.mono(9)).foregroundStyle(Brand.textTertiary) 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) } @@ -819,12 +829,82 @@ struct SettingsView: View { 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: { + Button { + expandedMenuBarItem = (expandedMenuBarItem == item.id) ? nil : item.id + } label: { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 11)) + .foregroundStyle(expandedMenuBarItem == item.id ? Brand.green : Brand.textTertiary) + } + .buttonStyle(.plain).accessibilityLabel(NSLocalizedString("Options", comment: "")) + Button { removeMenuBarItem(idx) } label: { Image(systemName: "minus.circle.fill").font(.system(size: 12)).foregroundStyle(Brand.textTertiary) } .buttonStyle(.plain).accessibilityLabel(NSLocalizedString("Remove", comment: "")) } - .padding(.vertical, 2) + } + + /// The expanded per-widget options — style, colour, label/value toggles, + /// and the style-specific extras (à la a desktop monitor's widget settings). + @ViewBuilder + private func menuBarItemOptions(index idx: Int, item: MenuBarItem) -> some View { + let style = item.resolvedStyle + VStack(spacing: 6) { + optionPicker("Style", value: style.title) { + ForEach(item.metric.styles) { st in + Button(st.title) { updateMenuBarItem(idx) { $0.style = st } } + } + } + optionPicker("Color", value: item.color.title) { + ForEach(MenuBarColorMode.allCases) { c in + Button(c.title) { updateMenuBarItem(idx) { $0.color = c } } + } + } + if style == .bar || style == .sparkline || style == .speed || style == .battery { + optionToggle("Show label", isOn: item.showLabel) { updateMenuBarItem(idx) { $0.showLabel.toggle() } } + } + if style == .bar || style == .sparkline || style == .battery { + optionToggle("Show value", isOn: item.showValue) { updateMenuBarItem(idx) { $0.showValue.toggle() } } + } + if style == .sparkline { + optionToggle("Filled", isOn: item.fill) { updateMenuBarItem(idx) { $0.fill.toggle() } } + optionPicker("History", value: "\(item.historyPoints)") { + ForEach([30, 60, 90, 120], id: \.self) { n in + Button("\(n)") { updateMenuBarItem(idx) { $0.historyPoints = n } } + } + } + } + if style == .speed { + optionPicker("Indicator", value: item.pictogram.title) { + ForEach(SpeedPictogram.allCases) { p in + Button(p.title) { updateMenuBarItem(idx) { $0.pictogram = p } } + } + } + optionToggle("Units", isOn: item.showUnits) { updateMenuBarItem(idx) { $0.showUnits.toggle() } } + } + } + } + + /// Settings sub-row: title on the left, a borderless menu on the right. + private func optionPicker(_ label: String, value: String, @ViewBuilder _ menu: () -> C) -> some View { + HStack { + Text(NSLocalizedString(label, comment: "")).font(Brand.sans(11)).foregroundStyle(Brand.textSecondary) + Spacer() + Menu { menu() } label: { + Text(value).font(Brand.mono(10)).foregroundStyle(Brand.textSecondary) + } + .menuStyle(.borderlessButton).fixedSize() + } + } + + /// Settings sub-row: title on the left, a compact switch on the right. + private func optionToggle(_ label: String, isOn: Bool, _ toggle: @escaping () -> Void) -> some View { + HStack { + Text(NSLocalizedString(label, comment: "")).font(Brand.sans(11)).foregroundStyle(Brand.textSecondary) + Spacer() + Toggle("", isOn: Binding(get: { isOn }, set: { _ in toggle() })) + .labelsHidden().toggleStyle(.switch).tint(Brand.green).controlSize(.mini) + } } private func updateMenuBarItem(_ idx: Int, _ mutate: (inout MenuBarItem) -> Void) { @@ -843,6 +923,12 @@ struct SettingsView: View { menuBarItems.swapAt(idx, j); commitMenuBarItems() } + private func removeMenuBarItem(_ idx: Int) { + guard menuBarItems.indices.contains(idx) else { return } + if expandedMenuBarItem == menuBarItems[idx].id { expandedMenuBarItem = nil } + menuBarItems.remove(at: idx); commitMenuBarItems() + } + /// Persist + re-render the status item (applyMenuBarVisibility re-runs the /// display mode when the icon is shown). private func commitMenuBarItems() { @@ -850,6 +936,114 @@ struct SettingsView: View { AppDelegate.shared?.applyMenuBarVisibility(Store.showMenuBarIcon) } + // MARK: - Popup contents editor (issue #82) + + @ViewBuilder + private var popupContentsEditor: some View { + VStack(spacing: 6) { + ForEach(PopupSection.allCases) { sec in + popupToggle(sec.title, on: popupSections.contains(sec)) { + if popupSections.contains(sec) { popupSections.remove(sec) } else { popupSections.insert(sec) } + Store.popupSections = popupSections + } + } + Rectangle().fill(Brand.hairline).frame(height: 1).padding(.vertical, 2) + subLabel("Metric tiles") + ForEach(MenuBarMetric.popupGrid) { m in + popupToggle(m.title, on: popupTiles.contains(m)) { + if popupTiles.contains(m) { popupTiles.remove(m) } else { popupTiles.insert(m) } + Store.popupTiles = popupTiles + } + } + } + .padding(.vertical, 2) + } + + private func popupToggle(_ label: String, on: Bool, _ toggle: @escaping () -> Void) -> some View { + HStack { + Text(label).font(Brand.sans(12)).foregroundStyle(Brand.textPrimary) + Spacer() + Toggle("", isOn: Binding(get: { on }, set: { _ in toggle() })) + .labelsHidden().toggleStyle(.switch).tint(Brand.green).controlSize(.small) + } + } + + // MARK: - Animated runner editor (RunCat-style) + + @ViewBuilder + private var runnerEditor: some View { + VStack(spacing: 8) { + HStack { + Text(NSLocalizedString("Animation", comment: "")).font(Brand.sans(12)).foregroundStyle(Brand.textPrimary) + Spacer() + Menu { + ForEach(RunnerCatalog.all) { b in + Button(b.title) { setRunnerSource(.builtIn(b.id)) } + } + Divider() + Button(NSLocalizedString("Choose GIF…", comment: "")) { importRunnerGIF() } + } label: { + Text(runnerSourceLabel).font(Brand.mono(10)).foregroundStyle(Brand.textSecondary) + } + .menuStyle(.borderlessButton).fixedSize() + } + HStack { + Text(NSLocalizedString("Speed tracks", comment: "")).font(Brand.sans(12)).foregroundStyle(Brand.textPrimary) + Spacer() + Menu { + ForEach(MenuBarMetric.allCases) { m in + Button(m.title) { runnerConfig.metric = m; commitRunner() } + } + } label: { + Text(runnerConfig.metric.title).font(Brand.mono(10)).foregroundStyle(Brand.textSecondary) + } + .menuStyle(.borderlessButton).fixedSize() + } + HStack { + Text(NSLocalizedString("Sensitivity", comment: "")).font(Brand.sans(12)).foregroundStyle(Brand.textPrimary) + Spacer() + Slider(value: Binding(get: { runnerConfig.sensitivity }, + set: { runnerConfig.sensitivity = $0; commitRunner() }), in: 0.5...2.0) + .frame(width: 160).tint(Brand.green) + } + popupToggle(NSLocalizedString("Show value next to runner", comment: ""), on: runnerConfig.showValue) { + runnerConfig.showValue.toggle(); commitRunner() + } + popupToggle(NSLocalizedString("Before widgets (in Metrics mode)", comment: ""), on: runnerConfig.prependToRow) { + runnerConfig.prependToRow.toggle(); commitRunner() + } + footnote("Built-in animations are drawn by Burrow; \u{201C}Choose GIF…\u{201D} plays your own. Speed scales with the chosen metric — busier is faster.") + } + .padding(.vertical, 2) + } + + private var runnerSourceLabel: String { + switch runnerConfig.source { + case .builtIn(let id): return RunnerCatalog.all.first { $0.id == id }?.title ?? id + case .gif: return NSLocalizedString("Custom GIF", comment: "") + } + } + + private func setRunnerSource(_ s: RunnerSource) { runnerConfig.source = s; commitRunner() } + + /// Pick a GIF and copy it into App Support. The picker blocks the main + /// thread by design, so pause the app-hang monitor for its duration. + private func importRunnerGIF() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.gif] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + let resp = CrashReporter.withoutAppHangTracking { panel.runModal() } + guard resp == .OK, let url = panel.url, let stored = RunnerGIF.importGIF(from: url) else { return } + runnerConfig.source = .gif(stored) + commitRunner() + } + + private func commitRunner() { + Store.runnerConfig = runnerConfig + AppDelegate.shared?.applyMenuBarVisibility(Store.showMenuBarIcon) + } + // MARK: - Status labels private func refreshStatusLabels() { diff --git a/Sources/StatusBarController.swift b/Sources/StatusBarController.swift index b6a42620..0c9829ac 100644 --- a/Sources/StatusBarController.swift +++ b/Sources/StatusBarController.swift @@ -31,6 +31,14 @@ final class StatusBarController: NSObject, NSMenuDelegate { /// at the snapshot cadence (net/disk sparklines read straight off the /// live ring instead). private var menuBarHistory: [MenuBarMetric: [Double]] = [:] + /// The animated runner (RunCat-style). A one-shot timer on main advances + /// frames and re-arms at an interval that tracks the chosen metric, so the + /// animation literally speeds up under load. + private let runner = RunnerEngine() + private var runnerTimer: DispatchSourceTimer? + /// Latest metric-row image, cached so the runner timer can composite + /// `frame + row` without re-rendering the row every frame (prepend mode). + private var cachedRowImage: NSImage? /// A small accent dot shown over the glyph when a Burrow self-update is /// available (driven by AppUpdate via .burrowUpdateAvailability). private let updateDot = NSView() @@ -98,25 +106,32 @@ final class StatusBarController: NSObject, NSMenuDelegate { /// Safe to call again to apply a settings change live. func applyDisplayMode() { guard let button = item.button else { return } - guard Store.menuBarDisplayMode == .metrics else { - metricsSub = nil - samplesSub = nil + metricsSub = nil + samplesSub = nil + switch Store.menuBarDisplayMode { + case .icon: + stopRunner() menuBarHistory.removeAll() button.image = BurrowIcons.menuBar button.imagePosition = .imageOnly button.title = "" - refreshUpdateDot() - return + case .metrics: + // 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() } + if Store.runnerConfig.prependToRow { startRunner() } else { stopRunner() } + renderMetrics() + case .runner: + // Standalone: the frame timer reads currentValues() each tick for + // both the animation speed and the optional value — no sinks needed. + menuBarHistory.removeAll() + startRunner() } - // 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() } @@ -133,7 +148,8 @@ final class StatusBarController: NSObject, NSMenuDelegate { private func appendHistory(_ m: MenuBarMetric, _ v: Double) { var ring = menuBarHistory[m] ?? [] ring.append(v) - if ring.count > 40 { ring.removeFirst(ring.count - 40) } + // Keep up to 120 — the largest sparkline history-points option. + if ring.count > 120 { ring.removeFirst(ring.count - 120) } menuBarHistory[m] = ring } @@ -143,13 +159,15 @@ final class StatusBarController: NSObject, NSMenuDelegate { /// 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 + let row = MenuBarRenderer.image(items: Store.menuBarItems, values: currentValues()) + cachedRowImage = row + // When the runner is prepended, the frame timer owns button.image (it + // composites frame + cachedRowImage); only draw the bare row otherwise. + if !Store.runnerConfig.prependToRow { + button.image = row ?? BurrowIcons.menuBar + button.imagePosition = .imageOnly + button.title = "" } - button.imagePosition = .imageOnly - button.title = "" refreshUpdateDot() } @@ -174,7 +192,7 @@ final class StatusBarController: NSObject, NSMenuDelegate { } 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) + let recent = live.samples.suffix(120) v.histories[.network] = recent.map { $0.rxMBs + $0.txMBs } v.histories[.diskIO] = recent.map { $0.readMBs + $0.writeMBs } v.histories[.cpu] = menuBarHistory[.cpu] @@ -183,8 +201,99 @@ final class StatusBarController: NSObject, NSMenuDelegate { return v } + // MARK: - Animated runner + + /// (Re)load frames for the current config and start the frame timer. + private func startRunner() { + runner.reload(Store.runnerConfig, height: MenuBarRenderer.height) { [weak self] in + self?.ensureRunnerTimer() + self?.runnerTick() // draw frame 0 immediately so there's no gap + } + } + + private func stopRunner() { + runnerTimer?.cancel() + runnerTimer = nil + } + + private func ensureRunnerTimer() { + guard runnerTimer == nil else { return } + let t = DispatchSource.makeTimerSource(queue: .main) + t.setEventHandler { [weak self] in self?.runnerTick() } + t.schedule(deadline: .now() + runner.interval(forUsage: runnerUsage())) + runnerTimer = t + t.resume() + } + + private func armRunnerTimer() { + runnerTimer?.schedule(deadline: .now() + runner.interval(forUsage: runnerUsage())) + } + + /// Advance one frame, draw it (standalone or composited with the row), and + /// re-arm at the latest speed. Cheap: a blit plus maybe one short string. + private func runnerTick() { + guard let button = item.button else { return } + let mode = Store.menuBarDisplayMode + let prepend = (mode == .metrics && Store.runnerConfig.prependToRow) + guard mode == .runner || prepend else { stopRunner(); return } + if let frame = runner.nextFrame() { + if prepend { + button.image = composite(frame: frame, trailing: cachedRowImage) + } else if Store.runnerConfig.showValue { + button.image = composite(frame: frame, trailing: runnerValueImage()) + } else { + button.image = frame + } + button.imagePosition = .imageOnly + button.title = "" + } + armRunnerTimer() + } + + /// Normalize the driving metric into a rough 0…100 for the speed mapping. + private func runnerUsage() -> Double { + let v = currentValues() + let m = Store.runnerConfig.metric + guard let p = v.primary[m] else { return 0 } + if m.isPercentage { return p } + switch m { + case .network, .diskIO: return min(100, (p + (v.secondary[m] ?? 0)) * 4) + case .fan: return min(100, p / 60) + case .temperature: return min(100, p) + default: return p + } + } + + /// The driving metric's value as a small image (standalone + "show value"). + private func runnerValueImage() -> NSImage? { + let text = MenuBarRenderer.valueText(Store.runnerConfig.metric, currentValues()) + let font = MenuBarRenderer.valueFontPublic + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: NSColor.labelColor] + let w = (text as NSString).size(withAttributes: attrs).width + let h = MenuBarRenderer.height + return NSImage(size: NSSize(width: max(1, w), height: h), flipped: false) { _ in + (text as NSString).draw(at: NSPoint(x: 0, y: (h - font.ascender + font.descender) / 2), withAttributes: attrs) + return true + } + } + + /// Lay a runner `frame` and an optional `trailing` image side by side. + private func composite(frame: NSImage, trailing: NSImage?) -> NSImage { + let h = MenuBarRenderer.height + let gap: CGFloat = trailing == nil ? 0 : 4 + let tw = trailing?.size.width ?? 0 + let w = frame.size.width + gap + tw + return NSImage(size: NSSize(width: max(1, w), height: h), flipped: false) { _ in + frame.draw(in: NSRect(x: 0, y: 0, width: frame.size.width, height: h)) + trailing?.draw(at: NSPoint(x: frame.size.width + gap, y: 0), + from: .zero, operation: .sourceOver, fraction: 1) + return true + } + } + deinit { if let o = updateObserver { NotificationCenter.default.removeObserver(o) } + runnerTimer?.cancel() // Explicitly remove the item so toggling the menu-bar setting off // (AppDelegate drops its reference) clears it from the bar at once. NSStatusBar.system.removeStatusItem(item) diff --git a/Sources/Store.swift b/Sources/Store.swift index a198fe10..796641c0 100644 --- a/Sources/Store.swift +++ b/Sources/Store.swift @@ -22,8 +22,30 @@ enum CacheRemovalMode: String { } /// What the menu-bar status item renders (see `Store.menuBarDisplayMode`). +/// * `.icon` — the Burrow mark. +/// * `.metrics` — the configured `menuBarItems` widget row. +/// * `.runner` — the animated runner icon (speed tracks a metric). enum MenuBarDisplayMode: String { - case icon, metrics + case icon, metrics, runner +} + +/// A section of the menu-bar popover (`PopupView`) the user can show/hide +/// (issue #82 — the popup is the surface they actually wanted to customize). +enum PopupSection: String, Codable, CaseIterable, Identifiable { + case header, chips, activity, metrics, battery, processes, utility, footer + var id: String { rawValue } + var title: String { + switch self { + case .header: return NSLocalizedString("Health header", comment: "") + case .chips: return NSLocalizedString("Hardware chips", comment: "") + case .activity: return NSLocalizedString("Activity (running jobs)", comment: "") + case .metrics: return NSLocalizedString("Metric tiles", comment: "") + case .battery: return NSLocalizedString("Battery card", comment: "") + case .processes: return NSLocalizedString("Top processes", comment: "") + case .utility: return NSLocalizedString("Utility strip", comment: "") + case .footer: return NSLocalizedString("Clean Watch footer", comment: "") + } + } } enum Store { @@ -337,6 +359,42 @@ enum Store { set { write(try? JSONEncoder().encode(newValue), "menu_bar_items") } } + /// Which popover sections the user wants visible. Default = all (the + /// historical full layout), so existing users see no change until they + /// customize. Stored as a JSON array of raw values. + static var popupSections: Set { + get { + guard let data = d.data(forKey: "popup_sections"), + let raw = try? JSONDecoder().decode([String].self, from: data) + else { return Set(PopupSection.allCases) } + return Set(raw.compactMap(PopupSection.init(rawValue:))) + } + set { write(try? JSONEncoder().encode(newValue.map(\.rawValue)), "popup_sections") } + } + + /// Which metric tiles the popover's grid shows. Default = all six. + static var popupTiles: Set { + get { + guard let data = d.data(forKey: "popup_tiles"), + let raw = try? JSONDecoder().decode([String].self, from: data) + else { return Set(MenuBarMetric.popupGrid) } + return Set(raw.compactMap(MenuBarMetric.init(rawValue:))) + } + set { write(try? JSONEncoder().encode(newValue.map(\.rawValue)), "popup_tiles") } + } + + /// The animated menu-bar runner (RunCat-style): an icon whose playback + /// speed tracks a chosen metric. Off by default. See `MenuBarRunner.swift`. + static var runnerConfig: RunnerConfig { + get { + guard let data = d.data(forKey: "runner_config"), + let cfg = try? JSONDecoder().decode(RunnerConfig.self, from: data) + else { return RunnerConfig() } + return cfg + } + set { write(try? JSONEncoder().encode(newValue), "runner_config") } + } + /// 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